"""
This module implements a simple mobile object with
a very rudimentary AI as well as an aggressive enemy
object based on that mobile class.
"""
import random
from evennia import TICKER_HANDLER
from evennia import search_object
from evennia import Command, CmdSet
from evennia import logger
from evennia.contrib.tutorial_world import objects as tut_objects
[docs]class CmdMobOnOff(Command):
"""
Activates/deactivates Mob
Usage:
mobon <mob>
moboff <mob>
This turns the mob from active (alive) mode
to inactive (dead) mode. It is used during
building to activate the mob once it's
prepared.
"""
key = "mobon"
aliases = "moboff"
locks = "cmd:superuser()"
[docs] def func(self):
"""
Uses the mob's set_alive/set_dead methods
to turn on/off the mob."
"""
if not self.args:
self.caller.msg("Usage: mobon||moboff <mob>")
return
mob = self.caller.search(self.args)
if not mob:
return
if self.cmdstring == "mobon":
mob.set_alive()
else:
mob.set_dead()
[docs]class MobCmdSet(CmdSet):
"""
Holds the admin command controlling the mob
"""
[docs] def at_cmdset_creation(self):
self.add(CmdMobOnOff())
[docs]class Mob(tut_objects.TutorialObject):
"""
This is a state-machine AI mobile. It has several states which are
controlled from setting various Attributes. All default to True:
patrolling: if set, the mob will move randomly
from room to room, but preferring to not return
the way it came. If unset, the mob will remain
stationary (idling) until attacked.
aggressive: if set, will attack Characters in
the same room using whatever Weapon it
carries (see tutorial_world.objects.Weapon).
if unset, the mob will never engage in combat
no matter what.
hunting: if set, the mob will pursue enemies trying
to flee from it, so it can enter combat. If unset,
it will return to patrolling/idling if fled from.
immortal: If set, the mob cannot take any damage.
irregular_echoes: list of strings the mob generates at irregular intervals.
desc_alive: the physical description while alive
desc_dead: the physical descripion while dead
send_defeated_to: unique key/alias for location to send defeated enemies to
defeat_msg: message to echo to defeated opponent
defeat_msg_room: message to echo to room. Accepts %s as the name of the defeated.
hit_msg: message to echo when this mob is hit. Accepts %s for the mob's key.
weapon_ineffective_msg: message to echo for useless attacks
death_msg: message to echo to room when this mob dies.
patrolling_pace: how many seconds per tick, when patrolling
aggressive_pace: -"- attacking
hunting_pace: -"- hunting
death_pace: -"- returning to life when dead
field 'home' - the home location should set to someplace inside
the patrolling area. The mob will use this if it should
happen to roam into a room with no exits.
"""
[docs] def at_init(self):
"""
When initialized from cache (after a server reboot), set up
the AI state.
"""
# The AI state machine (not persistent).
self.ndb.is_patrolling = self.db.patrolling and not self.db.is_dead
self.ndb.is_attacking = False
self.ndb.is_hunting = False
self.ndb.is_immortal = self.db.immortal or self.db.is_dead
[docs] def at_object_creation(self):
"""
Called the first time the object is created.
We set up the base properties and flags here.
"""
self.cmdset.add(MobCmdSet, permanent=True)
# Main AI flags. We start in dead mode so we don't have to
# chase the mob around when building.
self.db.patrolling = True
self.db.aggressive = True
self.db.immortal = False
# db-store if it is dead or not
self.db.is_dead = True
# specifies how much damage we divide away from non-magic weapons
self.db.damage_resistance = 100.0
# pace (number of seconds between ticks) for
# the respective modes.
self.db.patrolling_pace = 6
self.db.aggressive_pace = 2
self.db.hunting_pace = 1
self.db.death_pace = 100 # stay dead for 100 seconds
# we store the call to the tickerhandler
# so we can easily deactivate the last
# ticker subscription when we switch.
# since we will use the same idstring
# throughout we only need to save the
# previous interval we used.
self.db.last_ticker_interval = None
# store two separate descriptions, one for alive and
# one for dead (corpse)
self.db.desc_alive = "This is a moving object."
self.db.desc_dead = "A dead body."
# health stats
self.db.full_health = 20
self.db.health = 20
# when this mob defeats someone, we move the character off to
# some other place (Dark Cell in the tutorial).
self.db.send_defeated_to = "dark cell"
# text to echo to the defeated foe.
self.db.defeat_msg = "You fall to the ground."
self.db.defeat_msg_room = "%s falls to the ground."
self.db.weapon_ineffective_msg = (
"Your weapon just passes through your enemy, causing almost no effect!"
)
self.db.death_msg = "After the last hit %s evaporates." % self.key
self.db.hit_msg = "%s wails, shudders and writhes." % self.key
self.db.irregular_msgs = ["the enemy looks about.", "the enemy changes stance."]
self.db.tutorial_info = "This is an object with simple state AI, using a ticker to move."
def _set_ticker(self, interval, hook_key, stop=False):
"""
Set how often the given hook key should
be "ticked".
Args:
interval (int): The number of seconds
between ticks
hook_key (str): The name of the method
(on this mob) to call every interval
seconds.
stop (bool, optional): Just stop the
last ticker without starting a new one.
With this set, the interval and hook_key
arguments are unused.
In order to only have one ticker
running at a time, we make sure to store the
previous ticker subscription so that we can
easily find and stop it before setting a
new one. The tickerhandler is persistent so
we need to remember this across reloads.
"""
idstring = "tutorial_mob" # this doesn't change
last_interval = self.db.last_ticker_interval
last_hook_key = self.db.last_hook_key
if last_interval and last_hook_key:
# we have a previous subscription, kill this first.
TICKER_HANDLER.remove(
interval=last_interval, callback=getattr(self, last_hook_key), idstring=idstring
)
self.db.last_ticker_interval = interval
self.db.last_hook_key = hook_key
if not stop:
# set the new ticker
TICKER_HANDLER.add(
interval=interval, callback=getattr(self, hook_key), idstring=idstring
)
def _find_target(self, location):
"""
Scan the given location for suitable targets (this is defined
as Characters) to attack. Will ignore superusers.
Args:
location (Object): the room to scan.
Returns:
The first suitable target found.
"""
targets = [
obj
for obj in location.contents_get(exclude=self)
if obj.has_account and not obj.is_superuser
]
return targets[0] if targets else None
[docs] def set_alive(self, *args, **kwargs):
"""
Set the mob to "alive" mode. This effectively
resurrects it from the dead state.
"""
self.db.health = self.db.full_health
self.db.is_dead = False
self.db.desc = self.db.desc_alive
self.ndb.is_immortal = self.db.immortal
self.ndb.is_patrolling = self.db.patrolling
if not self.location:
self.move_to(self.home)
if self.db.patrolling:
self.start_patrolling()
[docs] def set_dead(self):
"""
Set the mob to "dead" mode. This turns it off
and makes sure it can take no more damage.
It also starts a ticker for when it will return.
"""
self.db.is_dead = True
self.location = None
self.ndb.is_patrolling = False
self.ndb.is_attacking = False
self.ndb.is_hunting = False
self.ndb.is_immortal = True
# we shall return after some time
self._set_ticker(self.db.death_pace, "set_alive")
[docs] def start_idle(self):
"""
Starts just standing around. This will kill
the ticker and do nothing more.
"""
self._set_ticker(None, None, stop=True)
[docs] def start_patrolling(self):
"""
Start the patrolling state by
registering us with the ticker-handler
at a leasurely pace.
"""
if not self.db.patrolling:
self.start_idle()
return
self._set_ticker(self.db.patrolling_pace, "do_patrol")
self.ndb.is_patrolling = True
self.ndb.is_hunting = False
self.ndb.is_attacking = False
# for the tutorial, we also heal the mob in this mode
self.db.health = self.db.full_health
[docs] def start_hunting(self):
"""
Start the hunting state
"""
if not self.db.hunting:
self.start_patrolling()
return
self._set_ticker(self.db.hunting_pace, "do_hunt")
self.ndb.is_patrolling = False
self.ndb.is_hunting = True
self.ndb.is_attacking = False
[docs] def start_attacking(self):
"""
Start the attacking state
"""
if not self.db.aggressive:
self.start_hunting()
return
self._set_ticker(self.db.aggressive_pace, "do_attack")
self.ndb.is_patrolling = False
self.ndb.is_hunting = False
self.ndb.is_attacking = True
[docs] def do_patrol(self, *args, **kwargs):
"""
Called repeatedly during patrolling mode. In this mode, the
mob scans its surroundings and randomly chooses a viable exit.
One should lock exits with the traverse:has_account() lock in
order to block the mob from moving outside its area while
allowing account-controlled characters to move normally.
"""
if random.random() < 0.01 and self.db.irregular_msgs:
self.location.msg_contents(random.choice(self.db.irregular_msgs))
if self.db.aggressive:
# first check if there are any targets in the room.
target = self._find_target(self.location)
if target:
self.start_attacking()
return
# no target found, look for an exit.
exits = [exi for exi in self.location.exits if exi.access(self, "traverse")]
if exits:
# randomly pick an exit
exit = random.choice(exits)
# move there.
self.move_to(exit.destination)
else:
# no exits! teleport to home to get away.
self.move_to(self.home)
[docs] def do_hunting(self, *args, **kwargs):
"""
Called regularly when in hunting mode. In hunting mode the mob
scans adjacent rooms for enemies and moves towards them to
attack if possible.
"""
if random.random() < 0.01 and self.db.irregular_msgs:
self.location.msg_contents(random.choice(self.db.irregular_msgs))
if self.db.aggressive:
# first check if there are any targets in the room.
target = self._find_target(self.location)
if target:
self.start_attacking()
return
# no targets found, scan surrounding rooms
exits = [exi for exi in self.location.exits if exi.access(self, "traverse")]
if exits:
# scan the exits destination for targets
for exit in exits:
target = self._find_target(exit.destination)
if target:
# a target found. Move there.
self.move_to(exit.destination)
return
# if we get to this point we lost our
# prey. Resume patrolling.
self.start_patrolling()
else:
# no exits! teleport to home to get away.
self.move_to(self.home)
[docs] def do_attack(self, *args, **kwargs):
"""
Called regularly when in attacking mode. In attacking mode
the mob will bring its weapons to bear on any targets
in the room.
"""
if random.random() < 0.01 and self.db.irregular_msgs:
self.location.msg_contents(random.choice(self.db.irregular_msgs))
# first make sure we have a target
target = self._find_target(self.location)
if not target:
# no target, start looking for one
self.start_hunting()
return
# we use the same attack commands as defined in
# tutorial_world.objects.Weapon, assuming that
# the mob is given a Weapon to attack with.
attack_cmd = random.choice(("thrust", "pierce", "stab", "slash", "chop"))
self.execute_cmd("%s %s" % (attack_cmd, target))
if target.db.health is None:
# This is not an attackable target
logger.log_err(f"{self.key} found {target} had an `health` attribute of `None`.")
return
# analyze the current state
if target.db.health <= 0:
# we reduced the target to <= 0 health. Move them to the
# defeated room
target.msg(self.db.defeat_msg)
self.location.msg_contents(self.db.defeat_msg_room % target.key, exclude=target)
send_defeated_to = search_object(self.db.send_defeated_to)
if send_defeated_to:
target.move_to(send_defeated_to[0], quiet=True)
else:
logger.log_err(
"Mob: mob.db.send_defeated_to not found: %s" % self.db.send_defeated_to
)
# response methods - called by other objects
[docs] def at_hit(self, weapon, attacker, damage):
"""
Someone landed a hit on us. Check our status
and start attacking if not already doing so.
"""
if self.db.health is None:
# health not set - this can't be damaged.
attacker.msg(self.db.weapon_ineffective_msg)
return
if not self.ndb.is_immortal:
if not weapon.db.magic:
# not a magic weapon - divide away magic resistance
damage /= self.db.damage_resistance
attacker.msg(self.db.weapon_ineffective_msg)
else:
self.location.msg_contents(self.db.hit_msg)
self.db.health -= damage
# analyze the result
if self.db.health <= 0:
# we are dead!
attacker.msg(self.db.death_msg)
self.set_dead()
else:
# still alive, start attack if not already attacking
if self.db.aggressive and not self.ndb.is_attacking:
self.start_attacking()
[docs] def at_new_arrival(self, new_character):
"""
This is triggered whenever a new character enters the room.
This is called by the TutorialRoom the mob stands in and
allows it to be aware of changes immediately without needing
to poll for them all the time. For example, the mob can react
right away, also when patrolling on a very slow ticker.
"""
# the room actually already checked all we need, so
# we know it is a valid target.
if self.db.aggressive and not self.ndb.is_attacking:
self.start_attacking()