Source code for evennia.contrib.tutorials.evadventure.npcs

"""
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 evennia.typeclasses.tags import TagProperty
from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import lazy_property, make_iter

from .ai import AIHandler
from .characters import LivingMixin
from .enums import Ability, WieldLocation
from .objects import get_bare_hands
from .rules import dice


[docs]class EvAdventureNPC(LivingMixin, DefaultCharacter): """ This is the base class for all non-player entities, including monsters. These generally don't advance in level but uses a simplified, abstract measure of how dangerous or competent they are - the 'hit dice' (HD). HD indicates how much health they have and how hard they hit. In _Knave_, HD also defaults to being the bonus for all abilities. HP is 4 x Hit die (this can then be customized per-entity of course). Morale is set explicitly per-NPC, usually between 7 and 9. Monsters don't use equipment in the way PCs do, instead they have a fixed armor value, and their Abilities are dynamically generated from the HD (hit_dice). 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). """ 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=get_bare_hands, autocreate=False) # instead of inventory coins = AttributeProperty(default=1, autocreate=False) # coin loot # if this npc is attacked, everyone with the same tag in the current location will also be # pulled into combat. group = TagProperty("npcs") @property def strength(self): return self.hit_dice @property def dexterity(self): return self.hit_dice @property def constitution(self): return self.hit_dice @property def intelligence(self): return self.hit_dice @property def wisdom(self): return self.hit_dice @property def charisma(self): return self.hit_dice @property def hp_max(self): return self.hit_dice * self.hp_multiplier
[docs] def at_object_creation(self): """ Start with max health. """ self.hp = self.hp_max self.tags.add("npcs", category="group")
[docs] def at_attacked(self, attacker, **kwargs): """ Called when being attacked and combat starts. """ pass
[docs] def ai_next_action(self, **kwargs): """ The combat engine should ask this method in order to get the next action the npc should perform in combat. """ pass
[docs]class EvAdventureTalkativeNPC(EvAdventureNPC): """ Talkative NPCs can be addressed by `talk [to] <npc>`. This opens a chat menu with communication options. The menu is created with the npc and we override the .create to allow passing in the menu nodes. """ menudata = AttributeProperty(dict(), autocreate=False) menu_kwargs = AttributeProperty(dict(), autocreate=False) # text shown when greeting at the start of a conversation. If this is an # iterable, a random reply will be chosen by the menu hi_text = AttributeProperty("Hi!", autocreate=False)
[docs] def at_damage(self, damage, attacker=None): """ Talkative NPCs are generally immortal (we don't deduct HP here by default)." """ attacker.msg(f'{self.key} dodges the damage and shouts "|wHey! What are you doing?|n"')
[docs] @classmethod def create(cls, key, account=None, **kwargs): """ Overriding the creation of the NPC, allowing some extra `**kwargs`. Args: key (str): Name of the new object. account (Account, optional): Account to attribute this object to. Keyword Args: description (str): Brief description for this object (same as default Evennia) ip (str): IP address of creator (for object auditing) (same as default Evennia). menudata (dict or str): The `menudata` argument to `EvMenu`. This is either a dict of `{"nodename": <node_callable>,...}` or the python-path to a module containing such nodes (see EvMenu docs). This will be used to generate the chat menu chat menu for the character that talks to the NPC (which means the `at_talk` hook is called (by our custom `talk` command). menu_kwargs (dict): This will be passed as `**kwargs` into `EvMenu` when it is created. Make sure this dict can be pickled to an Attribute. Returns: tuple: `(new_character, errors)`. On error, the `new_character` is `None` and `errors` is a `list` of error strings (an empty list otherwise). """ menudata = kwargs.pop("menudata", None) menu_kwargs = kwargs.pop("menu_kwargs", {}) # since this is a @classmethod we can't use super() here new_object, errors = EvAdventureNPC.create( key, account=account, attributes=(("menudata", menudata), ("menu_kwargs", menu_kwargs)) ) return new_object, errors
[docs] def at_talk(self, talker, startnode="node_start", session=None, **kwargs): """ Called by the `talk` command when another entity addresses us. Args: talker (Object): The one talking to us. startnode (str, optional): Allows to start in a different location in the menu tree. The given node must exist in the tree. session (Session, optional): The talker's current session, allows for routing correctly in multi-session modes. **kwargs: This will be passed into the `EvMenu` creation and appended and `menu_kwargs` given to the NPC at creation. Notes: We pass `npc=self` into the EvMenu for easy back-reference. This will appear in the `**kwargs` of the start node. """ menu_kwargs = {**self.menu_kwargs, **kwargs} EvMenu(talker, self.menudata, startnode=startnode, session=session, npc=self, **menu_kwargs)
[docs]def node_start(caller, raw_string, **kwargs): """ This is the intended start menu node for the Talkative NPC interface. It will use on-npc Attributes to build its message and will also pick its options based on nodes named `node_start_*` are available in the node tree. """ # we presume a back-reference to the npc this is added when the menu is created npc = kwargs["npc"] # grab a (possibly random) welcome text text = choice(make_iter(npc.hi_text)) # determine options based on `node_start_*` nodes available toplevel_node_keys = [ node_key for node_key in caller.ndb._evmenu._menutree if node_key.startswith("node_start_") ] options = [] for node_key in toplevel_node_keys: option_name = node_key[11:].replace("_", " ").capitalized() # we let the menu number the choices, so we don't use key here options.append({"desc": option_name, "goto": node_key}) return text, options
[docs]class EvAdventureQuestGiver(EvAdventureTalkativeNPC): """ An NPC that acts as a dispenser of quests. """
[docs]class EvAdventureShopKeeper(EvAdventureTalkativeNPC): """ ShopKeeper NPC. """ # how much extra the shopkeeper adds on top of the item cost upsell_factor = AttributeProperty(1.0, autocreate=False) # how much of the raw cost the shopkeep is willing to pay when buying from character miser_factor = AttributeProperty(0.5, autocreate=False) # prototypes of common wares common_ware_prototypes = AttributeProperty([], autocreate=False)
[docs] def at_damage(self, damage, attacker=None): """ Immortal - we don't deduct any damage here. """ attacker.msg( f"{self.key} brushes off the hit and shouts " '"|wHey! This is not the way to get a discount!|n"' )
[docs]class EvAdventureMob(EvAdventureNPC): """ Mob (mobile) NPC; this is usually an enemy. """ # change this to make the mob more or less likely to perform different actions combat_probabilities = { "hold": 0.0, "attack": 0.85, "stunt": 0.05, "item": 0.0, "flee": 0.05, }
[docs] @lazy_property def ai(self): return AIHandler(self)
[docs] def ai_idle(self): """ Do nothing. """ pass
[docs] def ai_combat(self): """ Manage the combat/combat state of the mob. """ if combathandler := self.nbd.combathandler: # already in combat allies, enemies = combathandler.get_sides(self) action = self.ai.random_probability(self.combat_probabilities) match action: case "hold": combathandler.queue_action({"key": "hold"}) case "combat": combathandler.queue_action({"key": "attack", "target": random.choice(enemies)}) case "stunt": # choose a random ally to help combathandler.queue_action( { "key": "stunt", "recipient": random.choice(allies), "advantage": True, "stunt": Ability.STR, "defense": Ability.DEX, } ) case "item": # use a random item on a random ally target = random.choice(allies) valid_items = [item for item in self.contents if item.at_pre_use(self, target)] combathandler.queue_action( {"key": "item", "item": random.choice(valid_items), "target": target} ) case "flee": self.ai.set_state("flee") elif not (targets := self.ai.get_targets()): self.ai.set_state("roam") else: target = random.choice(targets) self.execute_cmd(f"attack {target.key}")
[docs] def ai_roam(self): """ roam, moving randomly to a new room. If a target is found, switch to combat state. """ if targets := self.ai.get_targets(): self.ai.set_state("combat") self.execute_cmd(f"attack {random.choice(targets).key}") else: exits = self.ai.get_traversable_exits() if exits: exi = random.choice(exits) self.execute_cmd(f"{exi.key}")
[docs] def ai_flee(self): """ Flee from the current room, avoiding going back to the room from which we came. If no exits are found, switch to roam state. """ current_room = self.location past_room = self.attributes.get("past_room", category="ai_state", default=None) exits = self.ai.get_traversable_exits(exclude_destination=past_room) if exits: self.attributes.set("past_room", current_room, category="ai_state") exi = random.choice(exits) self.execute_cmd(f"{exi.key}") else: # if in a dead end, roam will allow for backing out self.ai.set_state("roam")
[docs] def at_defeat(self): """ Mobs die right away when defeated, no death-table rolls. """ self.at_death()