diff --git a/evennia/__init__.py b/evennia/__init__.py index 1ae50a98ca..4a64d06ba3 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -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, diff --git a/evennia/contrib/tutorials/evadventure/build_techdemo.py b/evennia/contrib/tutorials/evadventure/build_techdemo.py index a05085e21f..f819baef8c 100644 --- a/evennia/contrib/tutorials/evadventure/build_techdemo.py +++ b/evennia/contrib/tutorials/evadventure/build_techdemo.py @@ -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 diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index 623977764b..6c6f11d18d 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -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, ) diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 1a23d43bc6..8502c88009 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -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 " 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.") diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index 8cd934fec1..928aec3ed2 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -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): """ diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index 8fd2ad6749..a1b25e0d34 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -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. diff --git a/evennia/contrib/tutorials/evadventure/rooms.py b/evennia/contrib/tutorials/evadventure/rooms.py index eeef8d1893..326010d5fc 100644 --- a/evennia/contrib/tutorials/evadventure/rooms.py +++ b/evennia/contrib/tutorials/evadventure/rooms.py @@ -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 diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index 1ceb216705..2f0c561072 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -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")) diff --git a/evennia/contrib/tutorials/evadventure/tests/test_combat.py b/evennia/contrib/tutorials/evadventure/tests/test_combat.py index e95c7576dc..427d237a00 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_combat.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_combat.py @@ -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): diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index 2803c8de26..2a0b24d786 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -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: ::