"""
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()