Add defeat mode to tutorial combat

This commit is contained in:
Griatch 2022-07-17 19:10:51 +02:00
parent 2daadca999
commit 43e4917501
10 changed files with 419 additions and 131 deletions

View file

@ -48,6 +48,10 @@ ScriptDB = None
ChannelDB = None
Msg = None
# Properties
AttributeProperty = None
TagProperty = None
# commands
Command = None
CmdSet = None
@ -106,7 +110,7 @@ def _create_version():
Helper function for building the version string
"""
import os
from subprocess import check_output, CalledProcessError, STDOUT
from subprocess import STDOUT, CalledProcessError, check_output
version = "Unknown"
root = os.path.dirname(os.path.abspath(__file__))
@ -153,70 +157,63 @@ def _init():
global GLOBAL_SCRIPTS, OPTION_CLASSES
global EvMenu, EvTable, EvForm, EvMore, EvEditor
global ANSIString
global AttributeProperty, TagProperty
# Parent typeclasses
from .accounts.accounts import DefaultAccount
from .accounts.accounts import DefaultGuest
from .objects.objects import DefaultObject
from .objects.objects import DefaultCharacter
from .objects.objects import DefaultRoom
from .objects.objects import DefaultExit
from .comms.comms import DefaultChannel
from .scripts.scripts import DefaultScript
# Database models
from .objects.models import ObjectDB
from .accounts.models import AccountDB
from .scripts.models import ScriptDB
from .comms.models import ChannelDB
from .comms.models import Msg
# commands
from .commands.command import Command, InterruptCommand
from .commands.cmdset import CmdSet
# search functions
from .utils.search import search_object
from .utils.search import search_script
from .utils.search import search_account
from .utils.search import search_message
from .utils.search import search_channel
from .utils.search import search_help
from .utils.search import search_tag
# create functions
from .utils.create import create_object
from .utils.create import create_script
from .utils.create import create_account
from .utils.create import create_channel
from .utils.create import create_message
from .utils.create import create_help_entry
# utilities
from django.conf import settings
from .locks import lockfuncs
from .utils import logger
from .utils import gametime
from .utils import ansi
from .prototypes.spawner import spawn
from . import contrib
from .utils.evmenu import EvMenu
from .utils.evtable import EvTable
from .utils.evmore import EvMore
from .utils.evform import EvForm
from .utils.eveditor import EvEditor
from .utils.ansi import ANSIString
from .server import signals
# handlers
from .scripts.tickerhandler import TICKER_HANDLER
from .scripts.taskhandler import TASK_HANDLER
from .server.sessionhandler import SESSION_HANDLER
from . import contrib
from .accounts.accounts import DefaultAccount, DefaultGuest
from .accounts.models import AccountDB
from .commands.cmdset import CmdSet
from .commands.command import Command, InterruptCommand
from .comms.comms import DefaultChannel
from .comms.models import ChannelDB, Msg
from .locks import lockfuncs
from .objects.models import ObjectDB
from .objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from .prototypes.spawner import spawn
from .scripts.models import ScriptDB
from .scripts.monitorhandler import MONITOR_HANDLER
from .scripts.scripts import DefaultScript
from .scripts.taskhandler import TASK_HANDLER
from .scripts.tickerhandler import TICKER_HANDLER
from .server import signals
from .server.sessionhandler import SESSION_HANDLER
from .typeclasses.attributes import AttributeProperty
from .typeclasses.tags import TagProperty
from .utils import ansi, gametime, logger
from .utils.ansi import ANSIString
# containers
from .utils.containers import GLOBAL_SCRIPTS
from .utils.containers import OPTION_CLASSES
from .utils.containers import GLOBAL_SCRIPTS, OPTION_CLASSES
# create functions
from .utils.create import (
create_account,
create_channel,
create_help_entry,
create_message,
create_object,
create_script,
)
from .utils.eveditor import EvEditor
from .utils.evform import EvForm
from .utils.evmenu import EvMenu
from .utils.evmore import EvMore
from .utils.evtable import EvTable
# search functions
from .utils.search import (
search_account,
search_channel,
search_help,
search_message,
search_object,
search_script,
search_tag,
)
# API containers
@ -252,11 +249,11 @@ def _init():
"""
from .help.models import HelpEntry
from .accounts.models import AccountDB
from .scripts.models import ScriptDB
from .comms.models import Msg, ChannelDB
from .comms.models import ChannelDB, Msg
from .help.models import HelpEntry
from .objects.models import ObjectDB
from .scripts.models import ScriptDB
from .server.models import ServerConfig
from .typeclasses.attributes import Attribute
from .typeclasses.tags import Tag
@ -288,11 +285,11 @@ def _init():
"""
from .commands.default.cmdset_character import CharacterCmdSet
from .commands.default.cmdset_account import AccountCmdSet
from .commands.default.cmdset_unloggedin import UnloggedinCmdSet
from .commands.default.cmdset_character import CharacterCmdSet
from .commands.default.cmdset_session import SessionCmdSet
from .commands.default.muxcommand import MuxCommand, MuxAccountCommand
from .commands.default.cmdset_unloggedin import UnloggedinCmdSet
from .commands.default.muxcommand import MuxAccountCommand, MuxCommand
def __init__(self):
"populate the object with commands"
@ -305,12 +302,12 @@ def _init():
self.__dict__.update(dict([(c.__name__, c) for c in cmdlist]))
from .commands.default import (
account,
admin,
batchprocess,
building,
comms,
general,
account,
help,
system,
unloggedin,

View file

@ -28,6 +28,7 @@ from evennia.contrib.tutorials.evadventure.objects import (
EvAdventureObject,
EvAdventureObjectFiller,
EvAdventureRunestone,
EvAdventureWeapon,
)
from evennia.contrib.tutorials.evadventure.rooms import EvAdventureRoom
@ -65,10 +66,6 @@ create_object(
# with a static enemy
combat_room = create_object(EvAdventureRoom, key="Combat Arena", aliases=("evtechdemo#01",))
combat_room_enemy = create_object(
npcs.EvadventureMob, key="Training Dummy", aliases=("dummy",), location=combat_room
)
# link to/back to hub
hub_room = search_object("evtechdemo#00")[0]
create_object(
@ -81,3 +78,9 @@ create_object(
location=combat_room,
destination=hub_room,
)
# create training dummy with a stick
combat_room_enemy = create_object(
npcs.EvAdventureMob, key="Training Dummy", aliases=("dummy",), location=combat_room
)
weapon_stick = create_object(EvAdventureWeapon, key="stick", attributes=(("damage_roll", "1d2"),))
combat_room_enemy.weapon = weapon_stick

View file

@ -347,6 +347,8 @@ class LivingMixin:
"""
is_pc = False
@property
def hurt_level(self):
"""
@ -406,6 +408,46 @@ class LivingMixin:
"""
pass
def get_loot(self, looter):
"""
Called when being looted (after defeat).
Args:
looter (Object): The one doing the looting.
"""
max_steal = rules.dice.roll("1d10")
owned = self.coin
stolen = max(max_steal, owned)
self.coin -= stolen
looter.coin += stolen
self.location.msg_contents(
f"$You(looter) loots $You() for {stolen} coins!",
from_obj=self,
mapping={"looter": looter},
)
def pre_loot(self, defeated_enemy):
"""
Called just before looting an enemy.
Args:
defeated_enemy (Object): The enemy soon to loot.
"""
pass
def post_loot(self, defeated_enemy):
"""
Called just after having looted an enemy.
Args:
defeated_enemy (Object): The enemy just looted.
"""
pass
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
@ -414,6 +456,8 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
is_pc = True
# these are the ability bonuses. Defense is always 10 higher
strength = AttributeProperty(default=1)
dexterity = AttributeProperty(default=1)
@ -429,6 +473,7 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
hp_max = AttributeProperty(default=4)
level = AttributeProperty(default=1)
xp = AttributeProperty(default=0)
coins = AttributeProperty(default=0) # copper coins
morale = AttributeProperty(default=9) # only used for NPC/monster morale checks
@ -501,13 +546,11 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
rules.dice.roll_death(self)
if hp <= 0:
# this means we rolled death on the table
self.at_death()
else:
# still alive, but lost in some stats
if self.hp > 0:
# still alive, but lost some stats
self.location.msg_contents(
f"|y$You() $conj(stagger) back, weakened but still alive.|n", from_obj=self
f"|y$You() $conj(stagger) back and fall to the ground - alive, but unable to move.|n",
from_obj=self,
)
def at_death(self):
@ -516,5 +559,6 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
self.location.msg_contents(
f"|r$You() $conj(collapse) in a heap. No getting back from that.|n", from_obj=self
f"|r$You() $conj(collapse) in a heap.\nDeath embraces you ...|n",
from_obj=self,
)

View file

@ -105,10 +105,12 @@ from datetime import datetime
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 make_iter
from evennia.utils.utils import inherits_from, make_iter
from . import rules
from .characters import EvAdventureCharacter
from .enums import Ability
from .npcs import EvAdventureNPC
COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler"
COMBAT_HANDLER_INTERVAL = 60
@ -144,7 +146,7 @@ class CombatAction:
# the next combat menu node to go to - this ties the combat action into the UI
# use None to do nothing (jump directly to registering the action)
next_menu_node = "node_select_target"
next_menu_node = "node_select_action"
max_uses = None # None for unlimited
# in which order (highest first) to perform the action. If identical, use random order
@ -246,6 +248,7 @@ class CombatActionAttack(CombatAction):
desc = "[A]ttack/[C]ast spell at <target>"
aliases = ("a", "c", "attack", "cast")
help_text = "Make an attack using your currently equipped weapon/spell rune"
next_menu_node = "node_select_enemy_target"
priority = 1
@ -255,7 +258,7 @@ class CombatActionAttack(CombatAction):
"""
attacker = self.combatant
weapon = self.combatant.equipment.weapon
weapon = self.combatant.weapon
# figure out advantage (gained by previous stunts)
advantage = bool(self.combathandler.advantage_matrix[attacker].pop(defender, False))
@ -266,14 +269,14 @@ class CombatActionAttack(CombatAction):
attacker,
defender,
attack_type=weapon.attack_type,
defense_type=attacker.equipment.weapon.defense_type,
defense_type=attacker.weapon.defense_type,
advantage=advantage,
disadvantage=disadvantage,
)
self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}")
if is_hit:
# enemy hit, calculate damage
weapon_dmg_roll = attacker.equipment.weapon.damage_roll
weapon_dmg_roll = attacker.weapon.damage_roll
dmg = rules.dice.roll(weapon_dmg_roll)
@ -299,7 +302,7 @@ class CombatActionAttack(CombatAction):
# a miss
message = f" $You() $conj(miss) $You({defender.key})."
if quality is Ability.CRITICAL_FAILURE:
attacker.equipment.weapon.quality -= 1
attacker.weapon.quality -= 1
message += ".. it's a |rcritical miss!|n, damaging the weapon."
self.msg(message)
@ -325,6 +328,7 @@ class CombatActionStunt(CombatAction):
"A stunt does not cause damage but grants/gives advantage/disadvantage to future "
"actions. The effect needs to be used up within 5 turns."
)
next_menu_node = "node_select_enemy_target"
give_advantage = True # if False, give_disadvantage
max_uses = 1
@ -392,6 +396,7 @@ class CombatActionUseItem(CombatAction):
desc = "[U]se item"
aliases = ("u", "item", "use item")
help_text = "Use an item from your inventory."
next_menu_node = "node_select_friendly_target"
def get_help(self, item, *args):
return item.get_help(*args)
@ -456,7 +461,7 @@ class CombatActionFlee(CombatAction):
aliases = ("d", "disengage", "flee")
# this only affects us
next_menu_node = "node_register_action"
next_menu_node = "node_confirm_register_action"
help_text = (
"Disengage from combat. Use successfully two times in a row to leave combat at the "
@ -487,6 +492,7 @@ class CombatActionBlock(CombatAction):
"Move to block a target from fleeing combat. If you succeed "
"in a DEX vs DEX challenge, they don't get away."
)
next_menu_node = "node_select_enemy_target"
priority = -1 # must be checked BEFORE the flee action of the target!
@ -532,7 +538,7 @@ class CombatActionDoNothing(CombatAction):
help_text = "Hold you position, doing nothing."
# affects noone else
next_menu_node = "node_register_action"
next_menu_node = "node_confirm_register_action"
post_action_text = "{combatant} does nothing this turn."
@ -587,6 +593,7 @@ class EvAdventureCombatHandler(DefaultScript):
disadvantage_matrix = AttributeProperty(defaultdict(dict))
fleeing_combatants = AttributeProperty(list())
defeated_combatants = AttributeProperty(list())
_warn_time_task = None
@ -621,8 +628,10 @@ class EvAdventureCombatHandler(DefaultScript):
combatant,
{
"node_wait_start": node_wait_start,
"node_select_target": node_select_target,
"node_select_enemy_target": node_select_enemy_target,
"node_select_friendly_target": node_select_friendly_target,
"node_select_action": node_select_action,
"node_select_wield_from_inventory": node_select_wield_from_inventory,
"node_wait_turn": node_wait_turn,
},
startnode="node_wait_turn",
@ -660,18 +669,24 @@ class EvAdventureCombatHandler(DefaultScript):
self.msg(f"|y_______________________ start turn {self.turn} ___________________________|n")
for combatant in self.combatants:
# cycle combat menu
self._init_menu(combatant)
combatant.ndb._evmenu.goto("node_select_action", "")
if hasattr(combatant, "ai_combat_next_action"):
# NPC needs to get a decision from the AI
next_action_key, args, kwargs = combatant.ai_combat_next_action(self)
self.register_action(combatant, next_action_key, *args, **kwargs)
else:
# cycle combat menu for PC
self._init_menu(combatant)
combatant.ndb._evmenu.goto("node_select_action", "")
def _end_turn(self):
"""
End of turn operations.
1. Do all regular actions
2. Roll for any death events
2. Remove combatants that disengaged successfully
3. Timeout advantages/disadvantages
2. Check if fleeing combatants got away - remove them from combat
3. Check if anyone has hp <= - defeated
4. Check if any one side is alone on the battlefield - they loot the defeated
5. If combat is still on, update stunt timers
"""
self.msg(
@ -702,29 +717,66 @@ class EvAdventureCombatHandler(DefaultScript):
# handle disengaging combatants
to_remove = []
to_flee = []
to_defeat = []
for combatant in self.combatants:
# see if fleeing characters managed to do two flee actions in a row.
if (combatant in self.fleeing_combatants) and (combatant in already_fleeing):
self.fleeing_combatants.remove(combatant)
to_remove.append(combatant)
to_flee.append(combatant)
if combatant.hp <= 0:
# check characters that are beaten down.
# characters roll on the death table here, npcs usually just die
# characters roll on the death table here; but even if they survive, they
# count as defeated (unconcious) for this combat.
combatant.at_defeat()
if combatant.hp <= 0:
# if character still < 0 after at_defeat, it means they are dead.
# force-remove from combat.
to_remove.append(combatant)
to_defeat.append(combatant)
for combatant in to_remove:
# for clarity, we remove here rather than modifying the combatant list
# inside the previous loop
self.msg(f"|y$You() $conj(are) out of combat.|n", combatant=combatant)
for combatant in to_flee:
# combatant leaving combat by fleeing
self.msg(f"|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
self.remove_combatant(combatant)
for combatant in to_defeat:
# combatants leaving combat by being defeated
self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
self.combatants.remove(combatant)
self.defeated_combatants.append(combatant)
# check if only one side remains, divide into allies and enemies based on the first
# combatant,then check if either team is empty.
if not self.combatants:
# everyone's defeated at the same time. This is a tie where everyone loses and
# no looting happens.
self.msg("|yEveryone takes everyone else out. Today, noone wins.|n")
self.stop_combat()
return
else:
combatant = self.combatants[0]
allies = self.get_friendly_targets(combatant) # will always contain at least combatant
enemies = self.get_enemy_targets(combatant)
if not enemies:
# no enemies left - allies to combatant won!
defeated_enemies = self.get_enemy_targets(
combatant, all_combatants=self.defeated_combatants
)
# all surviving allies loot the fallen enemies
for ally in allies:
for enemy in defeated_enemies:
try:
ally.pre_loot(enemy)
enemy.get_loot(ally)
ally.post_loot(enemy)
except Exception:
logger.log_trace()
self.stop_combat()
return
# if we get here, combat is still on
# refresh stunt timeouts (note - self.stunt_duration is the same for
# all stunts; # for more complex use we could store the action and let action have a
# 'duration' property to use instead.
@ -753,10 +805,6 @@ class EvAdventureCombatHandler(DefaultScript):
self.advantage_matrix = new_advantage_matrix
self.disadvantage_matrix = new_disadvantage_matrix
if len(self.combatants) == 1:
# only one combatant left - abort combat
self.stop_combat()
def add_combatant(self, combatant, session=None):
"""
Add combatant to battle.
@ -775,7 +823,7 @@ class EvAdventureCombatHandler(DefaultScript):
"""
if combatant not in self.combatants:
self.combatants.append(combatant)
combatant.db.turnbased_combathandler = self
combatant.db.combathandler = self
# allow custom character actions (not used by default)
custom_action_classes = combatant.db.custom_combat_actions or []
@ -798,7 +846,7 @@ class EvAdventureCombatHandler(DefaultScript):
self.combatants.remove(combatant)
self.combatant_actions.pop(combatant, None)
combatant.ndb._evmenu.close_menu()
del combatant.db.turnbased_combathandler
del combatant.db.combathandler
def start_combat(self):
"""
@ -821,6 +869,73 @@ class EvAdventureCombatHandler(DefaultScript):
for combatant in self.combatants:
self.remove_combatant(combatant)
def get_enemy_targets(self, combatant, excluded=None, all_combatants=None):
"""
Get all valid targets the given combatant can target for an attack. This does not apply for
'friendly' targeting (like wanting to cast a heal on someone). We assume there are two types
of combatants - PCs (player-controlled characters and NPCs (AI-controlled). Here, we assume
npcs can never attack one another (or themselves)
For PCs to be able to target each other, the `allow_pvp`
Attribute flag must be set on the current `Room`.
Args:
combatant (Object): The combatant looking for targets.
excluded (list, optional): If given, these are not valid targets - this can be used to
avoid friendly NPCs.
all_combatants (list, optional): If given, use this list to get all combatants, instead
of using `self.combatants`.
"""
is_pc = not inherits_from(combatant, EvAdventureNPC)
allow_pvp = self.obj.allow_pvp
targets = []
combatants = all_combatants or self.combatants
if is_pc:
if allow_pvp:
# PCs may target everyone, including other PCs
targets = combatants
else:
# PCs may only attack NPCs
targets = [target for target in combatants if inherits_from(target, EvAdventureNPC)]
else:
# NPCs may only attack PCs, not each other
targets = [target for target in combatants if not inherits_from(target, EvAdventureNPC)]
if excluded:
targets = [target for target in targets if target not in excluded]
return targets
def get_friendly_targets(self, combatant, extra=None, all_combatants=None):
"""
Get a list of all 'friendly' or neutral targets a combatant may target, including
themselves.
Args:
combatant (Object): The combatant looking for targets.
extra (list, optional): If given, these are additional targets that can be
considered target for allied effects (could be used for a friendly NPC).
all_combatants (list, optional): If given, use this list to get all combatants, instead
of using `self.combatants`.
"""
is_pc = not inherits_from(combatant, EvAdventureNPC)
combatants = all_combatants or self.combatants
if is_pc:
# can target other PCs
targets = [target for target in combatants if not inherits_from(target, EvAdventureNPC)]
else:
# can target other NPCs
targets = [target for target in combatants if inherits_from(target, EvAdventureNPC)]
if extra:
targets = list(set(target + extra))
return targets
def get_combat_summary(self, combatant):
"""
Get a summary of the current combat state from the perspective of a
@ -973,7 +1088,7 @@ class EvAdventureCombatHandler(DefaultScript):
def _register_action(caller, raw_string, **kwargs):
"""
Register action with handler.
Actually register action with handler.
"""
action_key = kwargs.pop("action_key")
@ -987,22 +1102,41 @@ def _register_action(caller, raw_string, **kwargs):
return "node_wait_turn"
def node_select_target(caller, raw_string, **kwargs):
def node_confirm_register_action(caller, raw_string, **kwargs):
"""
Menu node allowing for selecting a target among all combatants. This combines
with all other actions.
Node where one can confirm registering the action or change one's mind.
"""
action_key = kwargs["action_key"]
action_target = kwargs.get("action_target", None) or ""
if action_target:
action_target = f", targeting {action_target.key}"
text = f"You will {action_key}{action_target}. Confirm? [Y]/n"
options = (
{
"key": "_default",
"goto": (_register_action, kwargs),
},
{"key": ("Abort/Cancel", "abort", "cancel", "a", "no", "n"), "goto": "node_select_action"},
)
def _select_target_helper(caller, raw_string, targets, **kwargs):
"""
Helper to select among only friendly or enemy targets (given by the calling node).
"""
combat = caller.ndb._evmenu.combathandler
action_key = kwargs["action_key"]
friendly_target = kwargs.get("target_friendly", False)
text = f"Select target for |w{action_key}|n."
# make the apply-self option always the first one, give it key 0
kwargs["action_target"] = caller
options = [{"key": "0", "desc": "(yourself)", "goto": (_register_action, kwargs)}]
# filter out ourselves and then make options for everyone else
combatants = [combatant for combatant in combat.combatants if combatant is not caller]
for inum, combatant in enumerate(combatants):
for inum, combatant in enumerate(targets):
kwargs["action_target"] = combatant
options.append(
{"key": str(inum + 1), "desc": combatant.key, "goto": (_register_action, kwargs)}
@ -1014,6 +1148,25 @@ def node_select_target(caller, raw_string, **kwargs):
return text, options
def node_select_enemy_target(caller, raw_string, **kwargs):
"""
Menu node allowing for selecting an enemy target among all combatants. This combines
with all other actions.
"""
targets = combat.get_enemy_targets(caller)
return _select_target_helper(caller, raw_string, targets, **kwargs)
def node_select_friendly_target(caller, raw_string, **kwargs):
"""
Menu node for selecting a friendly target among combatants (including oneself).
"""
targets = combat.get_friendly_targets(caller)
return _select_target_helper(caller, raw_string, targets, **kwargs)
def _item_broken(caller, raw_string, **kwargs):
caller.msg("|rThis item is broken and unusable!|n")
return None # back to previous node
@ -1080,9 +1233,7 @@ def node_select_use_item_from_inventory(caller, raw_string, **kwargs):
options.append({"desc": str(obj), "goto": (_register_action, kwargs)})
# add ability to cancel
options.append(
{"key": "_default", "desc": "(No input to Abort and go back)", "goto": "node_select_action"}
)
options.append({"key": "_default", "goto": "node_select_action"})
return text, options
@ -1236,6 +1387,9 @@ def join_combat(caller, *targets, session=None):
if not location:
raise CombatFailure("Must have a location to start combat.")
if not getattr(location, "allow_combat", False):
raise CombatFailure("This is not the time and place for picking a fight.")
if not targets:
raise CombatFailure("Must have an opponent to start combat.")

View file

@ -47,6 +47,10 @@ class Ability(Enum):
CRITICAL_FAILURE = "critical_failure"
CRITICAL_SUCCESS = "critical_success"
ALLEGIANCE_HOSTILE = "hostile"
ALLEGIANCE_NEUTRAL = "neutral"
ALLEGIANCE_FRIENDLY = "friendly"
class WieldLocation(Enum):
"""

View file

@ -2,11 +2,14 @@
EvAdventure NPCs. This includes both friends and enemies, only separated by their AI.
"""
from random import choice
from evennia import DefaultCharacter
from evennia.typeclasses.attributes import AttributeProperty
from .characters import LivingMixin
from .enums import Ability
from .objects import WeaponEmptyHand
class EvAdventureNPC(LivingMixin, DefaultCharacter):
@ -27,12 +30,25 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter):
If wanting monsters or NPCs that can level and work the same as PCs, base them off the
EvAdventureCharacter class instead.
The weapon of the npc is stored as an Attribute instead of implementing a full
inventory/equipment system. This means that the normal inventory can be used for
non-combat purposes (or for loot to get when killing an enemy).
"""
hit_dice = AttributeProperty(default=1)
armor = AttributeProperty(default=1) # +10 to get armor defense
morale = AttributeProperty(default=9)
hp = AttributeProperty(default=8)
is_pc = False
hit_dice = AttributeProperty(default=1, autocreate=False)
armor = AttributeProperty(default=1, autocreate=False) # +10 to get armor defense
morale = AttributeProperty(default=9, autocreate=False)
hp_multiplier = AttributeProperty(default=4, autocreate=False) # 4 default in Knave
hp = AttributeProperty(default=None, autocreate=False) # internal tracking, use .hp property
allegiance = AttributeProperty(default=Ability.ALLEGIANCE_HOSTILE, autocreate=False)
is_idle = AttributeProperty(default=False, autocreate=False)
weapon = AttributeProperty(default=WeaponEmptyHand, autocreate=False) # instead of inventory
coins = AttributeProperty(default=1, autocreate=False) # coin loot
@property
def strength(self):
@ -60,7 +76,7 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter):
@property
def hp_max(self):
return self.hit_dice * 4
return self.hit_dice * self.hp_multiplier
def at_object_creation(self):
"""
@ -98,6 +114,36 @@ class EvAdventureMob(EvAdventureNPC):
"""
def ai_combat_next_action(self, combathandler):
"""
Called to get the next action in combat.
Args:
combathandler (EvAdventureCombatHandler): The currently active combathandler.
Returns:
tuple: A tuple `(str, tuple, dict)`, being the `action_key`, and the `*args` and
`**kwargs` for that action. The action-key is that of a CombatAction available to the
combatant in the current combat handler.
"""
from .combat_turnbased import CombatActionAttack, CombatActionDoNothing
if self.is_idle:
# mob just stands around
return CombatActionDoNothing.key, (), {}
target = choice(combathandler.get_enemy_targets(self))
# simply randomly decide what action to take
action = choice(
(
CombatActionAttack,
CombatActionDoNothing,
)
)
return action.key, (target,), {}
def at_defeat(self):
"""
Mobs die right away when defeated, no death-table rolls.

View file

@ -5,8 +5,35 @@ EvAdventure rooms.
"""
from evennia import DefaultRoom
from evennia import AttributeProperty, DefaultRoom
class EvAdventureRoom(DefaultRoom):
pass
"""
Simple room supporting some EvAdventure-specifics.
"""
allow_combat = False
allow_pvp = False
allow_death = False
class EvAdventurePvPRoom(DefaultRoom):
"""
Room where PvP can happen, but noone gets killed.
"""
allow_combat = True
allow_pvp = True
class EvAdventureDungeonRoom(EvAdventureRoom):
"""
Dangerous dungeon room.
"""
allow_combat = True
allow_death = True

View file

@ -327,7 +327,7 @@ class EvAdventureRollEngine:
result = self.roll_random_table("1d8", death_table)
if result == "dead":
character.handle_death()
character.at_death()
else:
# survives with degraded abilities (1d4 roll)
abi = self.death_map[result]
@ -339,7 +339,7 @@ class EvAdventureRollEngine:
if current_abi < -10:
# can't lose more - die
character.handle_death()
character.at_death()
else:
# refresh health, but get permanent ability loss
new_hp = max(character.hp_max, self.roll("1d4"))

View file

@ -43,9 +43,14 @@ class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest):
)
def setUp(self):
super().setUp()
self.location.allow_combat = True
self.location.allow_death = True
self.combatant = self.character
self.target = create.create_object(
EvAdventureMob, key="testmonster", location=self.location
EvAdventureMob,
key="testmonster",
location=self.location,
attributes=(("is_idle", True),),
)
# this already starts turn 1
@ -56,10 +61,10 @@ class EvAdventureTurnbasedCombatHandlerTest(EvAdventureMixin, BaseEvenniaTest):
self.target.delete()
def test_remove_combatant(self):
self.assertTrue(bool(self.combatant.db.turnbased_combathandler))
self.assertTrue(bool(self.combatant.db.combathandler))
self.combathandler.remove_combatant(self.combatant)
self.assertFalse(self.combatant in self.combathandler.combatants)
self.assertFalse(bool(self.combatant.db.turnbased_combathandler))
self.assertFalse(bool(self.combatant.db.combathandler))
def test_start_turn(self):
self.combathandler._start_turn()
@ -150,9 +155,13 @@ class EvAdventureTurnbasedCombatActionTest(EvAdventureMixin, BaseEvenniaTest):
)
def setUp(self):
super().setUp()
self.location.allow_combat = True
self.location.allow_death = True
self.combatant = self.character
self.combatant2 = create.create_object(EvAdventureCharacter, key="testcharacter2")
self.target = create.create_object(EvAdventureMob, key="testmonster")
self.target = create.create_object(
EvAdventureMob, key="testmonster", attributes=(("is_idle", True),)
)
self.target.hp = 4
# this already starts turn 1
@ -186,6 +195,7 @@ class EvAdventureTurnbasedCombatActionTest(EvAdventureMixin, BaseEvenniaTest):
mock_randint.return_value = 11 # 11 + 1 str will hit beat armor 11
self._run_action(combat_turnbased.CombatActionAttack, self.target)
self.assertEqual(self.target.hp, -7)
self.assertTrue(self.target not in self.combathandler.combatants)
@patch("evennia.contrib.tutorials.evadventure.combat_turnbased.rules.randint")
def test_stunt_fail(self, mock_randint):

View file

@ -13,9 +13,8 @@ from collections import defaultdict
from django.conf import settings
from django.db import models
from evennia.utils.utils import to_str, make_iter
from evennia.locks.lockfuncs import perm as perm_lockfunc
from evennia.utils.utils import make_iter, to_str
_TYPECLASS_AGGRESSIVE_CACHE = settings.TYPECLASS_AGGRESSIVE_CACHE
@ -105,6 +104,10 @@ class TagProperty:
with an existing method/property on the class. If it does, you must use tags.add()
instead.
Note that while you _can_ check e.g. `obj.tagname,this will give an AttributeError
if the Tag is not set. Most often you want to use `obj.tags.get("tagname")` to check
if a tag is set on an object.
Example:
::