mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""
|
|
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.utils.evmenu import EvMenu
|
|
from evennia.utils.utils import make_iter
|
|
|
|
from .characters import LivingMixin
|
|
from .enums import Ability, WieldLocation
|
|
from .objects import WeaponEmptyHand
|
|
from .rules import dice
|
|
|
|
|
|
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=WeaponEmptyHand, autocreate=False) # instead of inventory
|
|
coins = AttributeProperty(default=1, autocreate=False) # coin loot
|
|
|
|
@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
|
|
|
|
def at_object_creation(self):
|
|
"""
|
|
Start with max health.
|
|
|
|
"""
|
|
self.hp = self.hp_max
|
|
|
|
def ai_combat_next_action(self):
|
|
"""
|
|
The combat engine should ask this method in order to
|
|
get the next action the npc should perform in combat.
|
|
|
|
"""
|
|
pass
|
|
|
|
|
|
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)
|
|
|
|
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"')
|
|
|
|
@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
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
|
|
|
|
class EvAdventureQuestGiver(EvAdventureTalkativeNPC):
|
|
"""
|
|
An NPC that acts as a dispenser of quests.
|
|
|
|
"""
|
|
|
|
|
|
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)
|
|
|
|
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"'
|
|
)
|
|
|
|
|
|
class EvAdventureMob(EvAdventureNPC):
|
|
"""
|
|
Mob (mobile) NPC; this is usually an enemy.
|
|
|
|
"""
|
|
|
|
# chance (%) that this enemy will loot you when defeating you
|
|
loot_chance = AttributeProperty(75, autocreate=False)
|
|
|
|
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 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.
|
|
|
|
"""
|
|
self.at_death()
|
|
|
|
def at_do_loot(self, looted):
|
|
"""
|
|
Called when mob gets to loot a PC.
|
|
|
|
"""
|
|
if dice.roll("1d100") > self.loot_chance:
|
|
# don't loot
|
|
return
|
|
|
|
if looted.coins:
|
|
# looter prefer coins
|
|
loot = dice.roll("1d20")
|
|
if looted.coins < loot:
|
|
self.location.msg_location(
|
|
"$You(looter) loots $You() for all coin!",
|
|
from_obj=looted,
|
|
mapping={"looter": self},
|
|
)
|
|
else:
|
|
self.location.msg_location(
|
|
"$You(looter) loots $You() for |y{loot}|n coins!",
|
|
from_obj=looted,
|
|
mapping={"looter": self},
|
|
)
|
|
elif hasattr(looted, "equipment"):
|
|
# go through backpack, first usable, then wieldable, wearable items
|
|
# and finally stuff wielded
|
|
stealable = looted.equipment.get_usable_objects_from_backpack()
|
|
if not stealable:
|
|
stealable = looted.equipment.get_wieldable_objects_from_backpack()
|
|
if not stealable:
|
|
stealable = looted.equipment.get_wearable_objects_from_backpack()
|
|
if not stealable:
|
|
stealable = [looted.equipment.slots[WieldLocation.SHIELD_HAND]]
|
|
if not stealable:
|
|
stealable = [looted.equipment.slots[WieldLocation.HEAD]]
|
|
if not stealable:
|
|
stealable = [looted.equipment.slots[WieldLocation.ARMOR]]
|
|
if not stealable:
|
|
stealable = [looted.equipment.slots[WieldLocation.WEAPON_HAND]]
|
|
if not stealable:
|
|
stealable = [looted.equipment.slots[WieldLocation.TWO_HANDS]]
|
|
|
|
stolen = looted.equipment.remove(choice(stealable))
|
|
stolen.location = self
|
|
|
|
self.location.msg_location(
|
|
"$You(looter) steals {stolen.key} from $You()!",
|
|
from_obj=looted,
|
|
mapping={"looter": self},
|
|
)
|