Split evadventure combat code into several modules

This commit is contained in:
Griatch 2023-04-09 11:11:58 +02:00
parent 6a4f293ab9
commit 049e4fbb35
5 changed files with 1905 additions and 1617 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,546 @@
"""
EvAdventure Base combat utilities.
This establishes the basic building blocks for combat:
- `CombatAction` - classes encompassing all the working around an action. They are initialized
from 'action-dicts` - dictionaries with all the relevant data for the particular invocation
- `CombatHandler` - base class for running a combat. Exactly how this is used depends on the
type of combat intended (twitch- or turn-based) so many details of this will be implemented
in child classes.
"""
import random
from collections import defaultdict, deque
from evennia import CmdSet, Command, create_script, default_cmds
from evennia.commands.command import InterruptCommand
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 display_len, inherits_from, list_to_string, pad
from . import rules
from .characters import EvAdventureCharacter
from .enums import ABILITY_REVERSE_MAP, Ability, ObjType
from .npcs import EvAdventureNPC
from .objects import EvAdventureObject
COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler"
COMBAT_HANDLER_INTERVAL = 30
class CombatFailure(RuntimeError):
"""
Some failure during actions.
"""
# 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
# store the action dicts' keys as properties accessible as e.g. action.target etc
for key, val in action_dict.items():
if not key.startswith("_"):
setattr(self, key, val)
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(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
def post_execute(self):
"""
Called after execution.
"""
pass
class CombatActionHold(CombatAction):
"""
Action that does nothing.
Note:
Refer to as 'hold'
action_dict = {
"key": "hold"
}
"""
class CombatActionAttack(CombatAction):
"""
A regular attack, using a wielded weapon.
action-dict = {
"key": "attack",
"target": Character/Object
}
Note:
Refer to as 'attack'
"""
def execute(self):
attacker = self.combatant
weapon = attacker.weapon
target = self.target
if weapon.at_pre_use(attacker, target):
weapon.use(attacker, target, advantage=self.has_advantage(attacker, target))
weapon.at_post_use(attacker, target)
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 = {
"key": "stunt",
"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):
combathandler = self.combathandler
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)
txt = ""
if 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
# 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=combathandler.has_advantage(attacker, defender),
disadvantage=combathandler.has_disadvantage(attacker, defender),
)
self.msg(f"$You() $conj(attempt) stunt on $You({defender.key}). {txt}")
# deal with results
if is_success:
if self.advantage:
combathandler.give_advantage(recipient, target)
else:
combathandler.give_disadvantage(recipient, target)
if recipient == self.combatant:
self.msg(
f"$You() $conj(gain) {'advantage' if self.advantage else 'disadvantage'} "
f"against $You({target.key})!"
)
else:
self.msg(
f"$You() $conj(cause) $You({recipient.key}) "
f"to gain {'advantage' if self.advantage else 'disadvantage'} "
f"against $You({target.key})!"
)
self.msg(
"|yHaving succeeded, you hold back to plan your next move.|n [hold]",
broadcast=False,
)
combathandler.queue_action(attacker, combathandler.default_action_dict)
else:
self.msg(f"$You({defender.key}) $conj(resist)! $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 = {
"key": "use",
"item": Object
"target": Character/NPC/Object/None
}
Note:
Refer to as 'use'
"""
def execute(self):
item = self.item
user = self.combatant
target = self.target
if item.at_pre_use(user, target):
item.use(
user,
target,
advantage=self.has_advantage(user, target),
disadvantage=self.has_disadvantage(user, target),
)
item.at_post_use(user, target)
# to back to idle after this
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
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 = {
"key": "wield",
"item": Object
}
Note:
Refer to as 'wield'.
"""
def execute(self):
self.combatant.equipment.move(self.item)
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
# main combathandler
class EvAdventureCombatHandlerBase(DefaultScript):
"""
This script is created when a combat starts. It 'ticks' the combat and tracks
all sides of it.
"""
# available actions in combat
action_classes = {
"hold": CombatActionHold,
"attack": CombatActionAttack,
"stunt": CombatActionStunt,
"use": CombatActionUseItem,
"wield": CombatActionWield,
}
# fallback action if not selecting anything
fallback_action_dict = AttributeProperty({"key": "hold"}, autocreate=False)
@classmethod
def get_or_create_combathandler(cls, obj, combathandler_key="combathandler", **kwargs):
"""
Get or create a combathandler on `obj`.
Args:
obj (any): The Typeclassed entity to store the CombatHandler Script on. This could be
a location (for turn-based combat) or a Character (for twitch-based combat).
Keyword Args:
combathandler_key (str): They key name for the script. Will be 'combathandler' by default.
**kwargs: Arguments to the Script, if it is created.
"""
if not obj:
raise CombatFailure("Cannot start combat without a place to do it!")
combathandler = obj.ndb.combathandler
if not combathandler:
combathandler = obj.scripts.get(combathandler_name).first()
if not combathandler:
# have to create from scratch
persistent = kwargs.pop("persistent", True)
combathandler = create_script(
cls, key=combathandler_key, obj=obj, persistent=persistent, **kwargs
)
self.caller.ndb.combathandler = combathandler
return combathandler
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 get_combat_summary(self, combatant):
"""
Get a 'battle report' - an overview of the current state of combat from the perspective
of one of the sides.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get.
Returns:
EvTable: A table representing the current state of combat.
Example:
::
Goblin shaman (Perfect)[attack]
Gregor (Hurt)[attack] Goblin brawler(Hurt)[attack]
Bob (Perfect)[stunt] vs Goblin grunt 1 (Hurt)[attack]
Goblin grunt 2 (Perfect)[hold]
Goblin grunt 3 (Wounded)[flee]
"""
allies, enemies = self.get_sides(combatant)
# we must include outselves at the top of the list (we are not returned from get_sides)
allies.insert(0, combatant)
nallies, nenemies = len(allies), len(enemies)
# prepare colors and hurt-levels
allies = [
f"{ally} ({ally.hurt_level})[{self.get_next_action_dict(ally)['key']}]"
for ally in allies
]
enemies = [
f"{enemy} ({enemy.hurt_level})[{self.get_next_action_dict(enemy)['key']}]"
for enemy in enemies
]
# the center column with the 'vs'
vs_column = ["" for _ in range(max(nallies, nenemies))]
vs_column[len(vs_column) // 2] = "|wvs|n"
# the two allies / enemies columns should be centered vertically
diff = abs(nallies - nenemies)
top_empty = diff // 2
bot_empty = diff - top_empty
topfill = ["" for _ in range(top_empty)]
botfill = ["" for _ in range(bot_empty)]
if nallies >= nenemies:
enemies = topfill + enemies + botfill
else:
allies = topfill + allies + botfill
# make a table with three columns
return evtable.EvTable(
table=[
evtable.EvColumn(*allies, align="l"),
evtable.EvColumn(*vs_column, align="c"),
evtable.EvColumn(*enemies, align="r"),
],
border=None,
maxwidth=78,
)
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).
"""
raise NotImplemented
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)
"""
raise NotImplemented
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.
"""
raise NotImplemented
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.
"""
raise NotImplemented
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.
"""
raise NotImplemented
def queue_action(self, combatant, action_dict):
"""
Queue an action by adding the new actiondict to the back of the queue. If the
queue was alrady at max-size, the front of the queue will be discarded.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action.
action_dict (dict): A dict describing the action class by name along with properties.
Example:
If the queue max-size is 3 and was `[a, b, c]` (where each element is an action-dict),
then using this method to add the new action-dict `d` will lead to a queue `[b, c, d]` -
that is, adding the new action will discard the one currently at the front of the queue
to make room.
"""
raise NotImplemented
def execute_next_action(self, combatant):
"""
Perform a combatant's next action.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action.
"""
raise NotImplemented
def start_combat(self):
"""
Start combat.
"""
raise NotImplemented
def check_stop_combat(self):
"""
Check if this combat should be aborted, whatever this means for the particular
the particular combat type.
Stop the combat immediately. This should also do all needed cleanup.
Keyword Args:
kwargs: Any extra keyword args used.
Returns:
bool: If `True`, the `stop_combat` method sho
"""
raise NotImplemented
def stop_combat(self):
"""
Stop combat. This should also do all cleanup.
"""
raise NotImplemented

View File

@ -0,0 +1,843 @@
"""
EvAdventure Turn-based combat
This implements a turn-based (Final Fantasy, etc) style of MUD combat.
choose their next action. If they don't react before a timer runs out, the previous action
will be repeated. This means that a 'twitch' style combat can be created using the same
mechanism, by just speeding up each 'turn'.
The combat is handled with a `Script` shared between all combatants; this tracks the state
of combat and handles all timing elements.
Unlike in base _Knave_, the MUD version's combat is simultaneous; everyone plans and executes
their turns simultaneously with minimum downtime.
This version is simplified to not worry about things like optimal range etc. So a bow can be used
the same as a sword in battle. One could add a 1D range mechanism to add more strategy by requiring
optimizal positioning.
The combat is controlled through a menu:
------------------- main menu
Combat
You have 30 seconds to choose your next action. If you don't decide, you will hesitate and do
nothing. Available actions:
1. [A]ttack/[C]ast spell at <target> using your equipped weapon/spell
3. Make [S]tunt <target/yourself> (gain/give advantage/disadvantage for future attacks)
4. S[W]ap weapon / spell rune
5. [U]se <item>
6. [F]lee/disengage (takes one turn, during which attacks have advantage against you)
8. [H]esitate/Do nothing
You can also use say/emote between rounds.
As soon as all combatants have made their choice (or time out), the round will be resolved
simultaneusly.
-------------------- attack/cast spell submenu
Choose the target of your attack/spell:
0: Yourself 3: <enemy 3> (wounded)
1: <enemy 1> (hurt)
2: <enemy 2> (unharmed)
------------------- make stunt submenu
Stunts are special actions that don't cause damage but grant advantage for you or
an ally for future attacks - or grant disadvantage to your enemy's future attacks.
The effects of stunts start to apply *next* round. The effect does not stack, can only
be used once and must be taken advantage of within 5 rounds.
Choose stunt:
1: Trip <target> (give disadvantage DEX)
2: Feint <target> (get advantage DEX against target)
3: ...
-------------------- make stunt target submenu
Choose the target of your stunt:
0: Yourself 3: <combatant 3> (wounded)
1: <combatant 1> (hurt)
2: <combatant 2> (unharmed)
------------------- swap weapon or spell run
Choose the item to wield.
1: <item1>
2: <item2> (two hands)
3: <item3>
4: ...
------------------- use item
Choose item to use.
1: Healing potion (+1d6 HP)
2: Magic pebble (gain advantage, 1 use)
3: Potion of glue (give disadvantage to target)
------------------- Hesitate/Do nothing
You hang back, passively defending.
------------------- Disengage
You retreat, getting ready to get out of combat. Use two times in a row to
leave combat. You flee last in a round.
"""
from .combat_base import (
CombatAction,
CombatActionHold,
CombatActionStunt,
CombatActionUserItem,
CombatActionWield,
EvAdventureCombatHandler,
)
from .enums import Ability
# turnbased-combat needs the flee action too
class CombatActionFlee(CombatAction):
"""
Start (or continue) fleeing/disengaging from combat.
action_dict = {
"key": "flee",
}
Note:
Refer to as 'flee'.
"""
def execute(self):
combathandler = self.combathandler
if self.combatant not in combathandler.fleeing_combatants:
# we record the turn on which we started fleeing
combathandler.fleeing_combatants[self.combatant] = self.combathandler.turn
# show how many turns until successful flight
current_turn = combathandler.turn
started_fleeing = combathandler.fleeing_combatants[self.combatant]
flee_timeout = combathandler.flee_timeout
time_left = flee_timeout - (current_turn - started_fleeing)
if time_left > 0:
self.msg(
"$You() $conj(retreat), being exposed to attack while doing so (will escape in "
f"{time_left} $pluralize(turn, {time_left}))."
)
class EvAdventureTurnbasedCombatHandler(EvAdventureCombatHandler):
"""
A version of the combathandler, handling turn-based combat.
"""
# available actions in combat
action_classes = {
"hold": CombatActionHold,
"attack": CombatActionAttack,
"stunt": CombatActionStunt,
"use": CombatActionUseItem,
"wield": CombatActionWield,
"flee": CombatActionFlee,
}
# how many turns you must be fleeing before escaping
flee_timeout = AttributeProperty(3, autocreate=False)
# how many turns you must be fleeing before escaping
flee_timeout = AttributeProperty(3, autocreate=False)
# fallback action if not selecting anything
fallback_action_dict = AttributeProperty({"key": "attack"}, autocreate=False)
# persistent storage
turn = AttributeProperty(0)
# who is involved in combat, and their queued action
# as {combatant: actiondict, ...}
combatants = AttributeProperty(dict)
# who has advantage against whom
advantage_matrix = AttributeProperty(defaultdict(dict))
disadvantage_matrix = AttributeProperty(defaultdict(dict))
fleeing_combatants = AttributeProperty(dict)
defeated_combatants = AttributeProperty(list)
# usable script properties
# .is_active - show if timer is running
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_matrix[recipient][target] = True
def give_disadvantage(self, recipient, target, **kwargs):
"""
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_matrix[recipient][target] = True
self.combathandler.advantage_matrix[recipient][target] = False
def has_advantage(self, combatant, target, **kwargs):
"""
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 bool(self.combathandler.advantage_matrix[recipient].pop(target, False)) or (
target in self.combathandler.fleeing_combatants
)
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.
"""
def has_disadvantage(self, recipient, target):
return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False)) or (
recipient in self.combathandler.fleeing_combatants
)
def add_combatant(self, combatant):
"""
Add a new combatant to the battle. Can be called multiple times safely.
Args:
*combatants (EvAdventureCharacter, EvAdventureNPC): Any number of combatants to add to
the combat.
Returns:
bool: If this combatant was newly added or not (it was already in combat).
"""
if combatant not in self.combatants:
self.combatants[combatant] = self.fallback_action_dict
return True
return False
def remove_combatant(self, combatant):
"""
Remove a combatant from the battle. This removes their queue.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant to add to
the combat.
"""
self.combatants.pop(combatant, None)
# clean up menu if it exists
if combatant.ndb._evmenu:
combatant.ndb._evmenu.close_menu()
def start_combat(self, **kwargs):
"""
This actually starts the combat. It's safe to run this multiple times
since it will only start combat if it isn't already running.
"""
if not self.is_active:
self.start(**kwargs)
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
queue was alrady at max-size, the front of the queue will be discarded.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action.
action_dict (dict): A dict describing the action class by name along with properties.
Example:
If the queue max-size is 3 and was `[a, b, c]` (where each element is an action-dict),
then using this method to add the new action-dict `d` will lead to a queue `[b, c, d]` -
that is, adding the new action will discard the one currently at the front of the queue
to make room.
"""
self.combatants[combatant] = action_dict
# track who inserted actions this turn (non-persistent)
did_action = set(self.ndb.did_action or ())
did_action.add(combatant)
if len(did_action) >= len(self.combatants):
# everyone has inserted an action. Start next turn without waiting!
self.force_repeat()
def get_next_action_dict(self, combatant, rotate_queue=True):
"""
Give the action_dict for the next action that will be executed.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant to get the action for.
rotate_queue (bool, optional): Rotate the queue after getting the action dict.
Returns:
dict: The next action-dict in the queue.
"""
return self.combatants.get(combatant, self.fallback_action_dict)
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 'hold'. We don't pop anything from the queue, instead we keep
rotating the queue. When the queue has a length of one, this means just repeating the
same action over and over.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing and action.
Example:
If the combatant's action queue is `[a, b, c]` (where each element is an action-dict),
then calling this method will lead to action `a` being performed. After this method, the
queue will be rotated to the left and be `[b, c, a]` (so next time, `b` will be used).
"""
# this gets the next dict and rotates the queue
action_dict = self.combatants.get(combatants, self.fallback_action_dict)
# 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(self, combatant, action_dict)
action.execute()
action.post_execute()
self.check_stop_combat()
def check_stop_combat(self):
# 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.combatants.keys()))
allies, enemies = self.get_sides(surviving_combatant)
if not enemies:
# if 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(comb for comb in self.defeated_combatants if comb.hp > 0)
killed = list_to_string(comb for comb in self.defeated_combatants if comb.hp <= 0)
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()
def at_repeat(self):
"""
This method is called every time Script repeats (every `interval` seconds). Performs a full
turn of combat, performing everyone's actions in random order.
"""
self.turn += 1
# random turn order
combatants = list(self.combatants.keys())
random.shuffle(combatants) # shuffles in place
# 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.combatants.pop(combatant)
self.defeated_combatants.append(combatant)
self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
# check if anyone managed to flee
flee_timeout = self.flee_timeout
for combatant, started_fleeing in self.fleeing_combatants.items():
if self.turn - started_fleeing >= flee_timeout:
# if they are still alive/fleeing and have been fleeing long enough, escape
self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
self.remove_combatant(combatant)
# check if one side won the battle
self.check_stop_combat()
# -----------------------------------------------------------------------------------
#
# Turn-based combat (Final Fantasy style), using a menu
#
# Activate by adding the CmdTurnCombat command to Character cmdset, then
# use it to attack a target.
#
# -----------------------------------------------------------------------------------
def _get_combathandler(caller):
turn_length = 30
flee_timeout = 3
return EvAdventureTurnbasedCombatHandler.get_or_create_combathandler(
caller.location,
attributes=[("turn_length", turn_length), ("flee_timeout", flee_timeout)],
)
def _queue_action(caller, raw_string, **kwargs):
action_dict = kwargs["action_dict"]
_get_combathandler(caller).queue_action(caller, action_dict)
return "node_combat"
def _step_wizard(caller, raw_string, **kwargs):
"""
Many options requires stepping through several steps, wizard style. This
will redirect back/forth in the sequence.
E.g. Stunt boost -> Choose ability to boost -> Choose recipient -> Choose target -> queue
"""
caller.msg(f"_step_wizard kwargs: {kwargs}")
steps = kwargs.get("steps", [])
nsteps = len(steps)
istep = kwargs.get("istep", -1)
# one of abort, back, forward
step_direction = kwargs.get("step", "forward")
match step_direction:
case "abort":
# abort this wizard, back to top-level combat menu, dropping changes
return "node_combat"
case "back":
# step back in wizard
if istep <= 0:
return "node_combat"
istep = kwargs["istep"] = istep - 1
return steps[istep], kwargs
case _:
# forward (default)
if istep >= nsteps - 1:
# we are already at end of wizard - queue action!
return _queue_action(caller, raw_string, **kwargs)
else:
# step forward
istep = kwargs["istep"] = istep + 1
return steps[istep], kwargs
def _get_default_wizard_options(caller, **kwargs):
"""
Get the standard wizard options for moving back/forward/abort. This can be appended to
the end of other options.
"""
return [
{"key": ("back", "b"), "goto": (_step_wizard, {**kwargs, **{"step": "back"}})},
{"key": ("abort", "a"), "goto": (_step_wizard, {**kwargs, **{"step": "abort"}})},
]
def node_choose_enemy_target(caller, raw_string, **kwargs):
"""
Choose an enemy as a target for an action
"""
text = "Choose an enemy to target."
action_dict = kwargs["action_dict"]
combathandler = _get_combathandler(caller)
_, enemies = combathandler.get_sides(caller)
options = [
{
"desc": target.get_display_name(caller),
"goto": (
_step_wizard,
{**kwargs, **{"action_dict": {**action_dict, **{"target": target}}}},
),
}
for target in enemies
]
options.extend(_get_default_wizard_options(caller, **kwargs))
return text, options
def node_choose_allied_target(caller, raw_string, **kwargs):
"""
Choose an enemy as a target for an action
"""
text = "Choose an ally to target."
action_dict = kwargs["action_dict"]
combathandler = _get_combathandler(caller)
allies, _ = combathandler.get_sides(caller)
# can choose yourself
options = [
{
"desc": "Yourself",
"goto": (
_step_wizard,
{
**kwargs,
**{
"action_dict": {
**{**action_dict, **{"target": caller, "recipient": caller}}
}
},
},
),
}
]
options.extend(
[
{
"desc": target.get_display_name(caller),
"goto": (
_step_wizard,
{
**kwargs,
**{
"action_dict": {
**action_dict,
**{"target": target, "recipient": target},
}
},
},
),
}
for target in allies
]
)
options.extend(_get_default_wizard_options(caller, **kwargs))
return text, options
def node_choose_ability(caller, raw_string, **kwargs):
"""
Select an ability to use/boost etc.
"""
text = "Choose the ability to apply"
action_dict = kwargs["action_dict"]
options = [
{
"desc": abi.value,
"goto": (
_step_wizard,
{
**kwargs,
**{
"action_dict": {**action_dict, **{"stunt_type": abi, "defense_type": abi}},
},
},
),
}
for abi in (
Ability.STR,
Ability.DEX,
Ability.CON,
Ability.INT,
Ability.INT,
Ability.WIS,
Ability.CHA,
)
]
options.extend(_get_default_wizard_options(caller, **kwargs))
return text, options
def node_choose_use_item(caller, raw_string, **kwargs):
"""
Choose item to use.
"""
text = "Select the item"
action_dict = kwargs["action_dict"]
options = [
{
"desc": item.get_display_name(caller),
"goto": (_step_wizard, {**kwargs, **{**action_dict, **{"item": item}}}),
}
for item in caller.equipment.get_usable_objects_from_backpack()
]
if not options:
text = "There are no usable items in your inventory!"
options.extend(_get_default_wizard_options(caller, **kwargs))
return text, options
def node_choose_wield_item(caller, raw_string, **kwargs):
"""
Choose item to use.
"""
text = "Select the item"
action_dict = kwargs["action_dict"]
options = [
{
"desc": item.get_display_name(caller),
"goto": (_step_wizard, {**kwargs, **{**action_dict, **{"item": item}}}),
}
for item in caller.equipment.get_wieldable_objects_from_backpack()
]
if not options:
text = "There are no items in your inventory that you can wield!"
options.extend(_get_default_wizard_options(caller, **kwargs))
return text, options
def node_combat(caller, raw_string, **kwargs):
"""Base combat menu"""
combathandler = _get_combathandler(caller)
text = combathandler.get_combat_summary(caller)
options = [
{
"desc": "attack an enemy",
"goto": (
_step_wizard,
{
"steps": ["node_choose_enemy_target"],
"action_dict": {"key": "attack", "target": None},
},
),
},
{
"desc": "Stunt - gain a later advantage against a target",
"goto": (
_step_wizard,
{
"steps": [
"node_choose_ability",
"node_choose_allied_target",
"node_choose_enemy_target",
],
"action_dict": {"key": "stunt", "advantage": True},
},
),
},
{
"desc": "Stunt - give an enemy disadvantage against yourself or an ally",
"goto": (
_step_wizard,
{
"steps": [
"node_choose_ability",
"node_choose_enemy_target",
"node_choose_allied_target",
],
"action_dict": {"key": "stunt", "advantage": False},
},
),
},
{
"desc": "Use an item on yourself or an ally",
"goto": (
_step_wizard,
{
"steps": ["node_choose_use_item", "node_choose_allied_target"],
"action_dict": {"key": "use", "item": None, "target": None},
},
),
},
{
"desc": "Use an item on an enemy",
"goto": (
_step_wizard,
{
"steps": ["node_choose_use_item", "node_choose_enemy_target"],
"action_dict": {"key": "use", "item": None, "target": None},
},
),
},
{
"desc": "Wield/swap with an item from inventory",
"goto": (
_step_wizard,
{
"steps": ["node_choose_wield_item"],
"action_dict": {"key": "wield", "item": None},
},
),
},
{
"desc": "flee!",
"goto": (_queue_action, {"action_dict": {"key": "flee"}}),
},
{
"desc": "hold, doing nothing",
"goto": (_queue_action, {"action_dict": {"key": "hold"}}),
},
]
return text, options
# Add this command to the Character cmdset to make turn-based combat available.
class _CmdTurnCombatBase(_CmdCombatBase):
"""
Override parent class to slow down the tick for more clearly turn-based play.
"""
combathandler_name = "combathandler"
combat_tick = 30
flee_timeout = 2
class CmdTurnAttack(_CmdTurnCombatBase):
"""
Start or join combat.
Usage:
attack [<target>]
"""
key = "attack"
aliases = ["hit", "turnbased combat"]
def parse(self):
super().parse()
self.args = self.args.strip()
def func(self):
if not self.args:
self.msg("What are you attacking?")
return
target = self.caller.search(self.args)
if not target:
return
if not hasattr(target, "hp"):
self.msg(f"You can't attack that.")
return
elif target.hp <= 0:
self.msg(f"{target.get_display_name(self.caller)} is already down.")
return
if target.is_pc and not target.location.allow_pvp:
self.msg("PvP combat is not allowed here!")
return
# add combatants to combathandler. this can be done safely over and over
self.combathandler.add_combatant(self.caller)
self.combathandler.queue_action(self.caller, {"key": "attack", "target": target})
self.combathandler.add_combatant(target)
self.combathandler.start_combat()
# build and start the menu
evmenu.EvMenu(
self.caller,
{
"node_choose_enemy_target": node_choose_enemy_target,
"node_choose_allied_target": node_choose_allied_target,
"node_choose_ability": node_choose_ability,
"node_choose_use_item": node_choose_use_item,
"node_choose_wield_item": node_choose_wield_item,
"node_combat": node_combat,
},
startnode="node_combat",
combathandler=self.combathandler,
# cmdset_mergetype="Union",
persistent=True,
)
class TurnAttackCmdSet(CmdSet):
"""
CmdSet for the turn-based combat.
"""
def at_cmdset_creation(self):
self.add(CmdTurnAttack())

View File

@ -0,0 +1,503 @@
"""
EvAdventure Twitch-based combat
This implements a 'twitch' (aka DIKU or other traditional muds) style of MUD combat.
"""
from evennia import AttributeProperty
from evennia.commands.command import Command, InterruptCommand
from evennia.scripts.scripts import DefaultScript
from evennia.utils.create import create_script
from evennia.utils.utils import repeat, unrepeat
from .combat import (
CombatActionAttack,
CombatActionHold,
CombatActionStunt,
CombatActionUserItem,
CombatActionWield,
EvAdventureCombatHandlerBase,
)
from .enums import ABILITY_REVERSE_MAP, Ability, ObjType
class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
"""
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
advantages_against = AttributeProperty(dict)
disadvantages_against = AttributeProperty(dict)
action_dict = AttributeProperty(dict)
fallback_action_dict = AttributePropety({"key": "hold", "dt": 0})
# stores the current ticker reference, so we can manipulate it later
current_ticker_ref = AttributeProperty(None)
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)
]
if self.obj.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 = [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 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.advantages_against[target] = True
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.disadvantages_against[target] = True
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.advantages_against.get(target, False)
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.disadvantages_against.get(target, False)
def queue_action(self, action_dict):
"""
Schedule the next action to fire.
Args:
action_dict (dict): The new action-dict to initialize.
"""
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.tickerhandler_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.tickerhandler_ref = repeat(dt, self.execute_next_action, id_string="combat")
def execute_next_action(self):
"""
Triggered after a delay by the command
"""
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 = fallback_action_dict
self.queue_action(fallback_action_dict.get("dt", 0))
def check_stop_combat(self):
# check if one side won the battle.
allies, enemies = self.get_sides()
if not allies and not enemies:
txt = "Noone stands after the dust settles."
self.msg(txt)
return
if not allies or not enemies:
still_standing = list_to_string(
f"$You({comb.key})" for comb in allies + enemies if comb.hp > 0
)
self.msg(f"The combat is over. {still_standing} are still standing.")
self.stop_combat()
def stop_combat(self):
"""
Stop combat immediately.
"""
self.queue_action({"key": "hold", "dt": 0}) # make sure ticker is killed
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 all supported combat syntaxes.
<action> [<target>|<item>]
or
<action> <item> [on] <target>
Use 'on' to differentiate if names/items have spaces in the name.
"""
args = self.args.strip()
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, combathandler_name="combathandler"):
"""
Get or create the combathandler assigned to this combatant.
"""
return EvAdventureCombatHandlerBase.get_or_create_combathandler(self.caller)
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"
def func(self):
target = self.search(lhs)
if not target:
return
combathandler = self.get_or_create_combathandler()
# 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})
combathandler.msg("$You() attacks $You(target.key)!", self.caller)
class CmdLook(default_cmds.CmdLook):
def func(self):
# get regular look, followed by a combat summary
super().func()
if not self.args:
combathandler = self.get_or_create_combathandler(self.caller.location)
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}")
class CmdHold(_BaseTwitchCombatCommand):
"""
Hold back your blows, doing nothing.
Usage:
hold
"""
key = "hold"
def func(self):
combathandler = self.get_or_create_combathandler()
combathandler.queue_action({"key": "hold"})
combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
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"
def parse(self):
super().parse()
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)
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 stunt_type.strip() not in ABILITY_REVERSE_MAP:
self.msg("That's not a valid ability.")
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.strip()]
self.recipient = recipient.strip()
self.target = target.strip()
def func(self):
combathandler = self.get_or_create_combathandler()
target = self.caller.search(self.target, candidates=combathandler.combatants.keys())
if not target:
return
recipient = self.caller.search(self.recipient, candidates=combathandler.combatants.keys())
if not recipient:
return
combathandler.queue_action(
self.caller,
{
"key": "stunt",
"recipient": recipient,
"target": target,
"advantage": self.advantage,
"stunt_type": self.stunt_type,
"defense_type": self.stunt_type,
},
)
combathandler.msg("$You() prepare a stunt!", self.caller)
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"
def parse(self):
super().parse()
args = self.args
if not args:
self.msg("What do you want to use?")
raise InterruptCommand()
elif "on" in args:
self.item, self.target = (part.strip() for part in args.split("on", 1))
else:
self.item, *target = args.split(None, 1)
self.target = target[0] if target else "me"
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()
combathandler.queue_action(self.caller, {"key": "use", "item": item, "target": self.target})
combathandler.msg(
f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
)
class CmdWield(_CmdCombatBase):
"""
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"
def parse(self):
if not self.args:
self.msg("What do you want to wield?")
raise InterruptCommand()
super().parse()
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(self.caller, {"key": "wield", "item": item})
combathandler.msg(
f"$You() start wielding {item.get_display_name(self.caller)}!", self.caller
)
class TwitchAttackCmdSet(CmdSet):
"""
Add to character, to be able to attack others in a twitch-style way.
"""
def at_cmdset_creation(self):
self.add(CmdAttack())
self.add(CmdLook())
self.add(CmdHold())
self.add(CmdStunt())
self.add(CmdUseItem())
self.add(CmdWield())

View File

@ -6,7 +6,6 @@ added to all game objects. You access it through the property
"""
from django.utils.translation import gettext as _
from evennia.scripts.models import ScriptDB
from evennia.utils import create, logger
@ -112,6 +111,19 @@ class ScriptHandler(object):
num += 1
return num
def has(self, key):
"""
Determine if a given script exists on this object.
Args:
key (str): Search criterion, the script's key or dbref.
Returns:
bool: If the script exists or not.
"""
return ScriptDB.objects.get_all_scripts_on_obj(self.obj, key=key).exists()
def get(self, key):
"""
Search scripts on this object.