mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Start implementing ai-states for beginner tutorial
This commit is contained in:
parent
7c70618326
commit
6e6ab208a6
7 changed files with 298 additions and 372 deletions
|
|
@ -1,5 +1,42 @@
|
|||
# Changelog
|
||||
|
||||
## Evennia Main branch
|
||||
|
||||
- Feature: *Backwards incompatible*: `DefaultObject.get_numbered_name` now gets object's
|
||||
name via `.get_display_name` for better compatibility with recog systems.
|
||||
- Feature: *Backwards incompatible*: Removed the (#dbref) display from
|
||||
`DefaultObject.get_display_name`, instead using new `.get_extra_display_name_info`
|
||||
method for getting this info. The Object's display template was extended for
|
||||
optionally adding this information. This makes showing extra object info to
|
||||
admins an explicit action and opens up `get_display_name` for general use.
|
||||
- Feature: Add `ON_DEMAND_HANDLER.set_dt(key, category, dt)` and
|
||||
`.set_stage(key, category, stage)` to allow manual tweaking of task timings,
|
||||
for example for a spell speeding a plant's growth (Griatch)
|
||||
- Feature: Add `use_assertequal` kwarg to the `EvenniaCommandTestMixin` testing
|
||||
class; this uses django's `assertEqual` over the default more lenient checker,
|
||||
which can be useful for testing table whitespace (Griatch)
|
||||
- Feature: New `utils.group_objects_by_key_and_desc` for grouping a list of
|
||||
objects based on the visible key and desc. Useful for inventory listings (Griatch)
|
||||
- Feature: Add `DefaultObject.get_numbered_name` `return_string` bool kwarg, for only
|
||||
returning singular/plural based on count instead of a tuple with both (Griatch)
|
||||
- [Fix][issue3443] Removed the `@reboot` alias to `@reset` to not mislead people
|
||||
into thinking you can do a portal+server reboot from in-game (you cannot) (Griatch)
|
||||
- Fix: `DefaultObject.get_numbered_name` used `.name` instead of
|
||||
`.get_display_name` which broke recog systems. May lead to object's #dbref
|
||||
will show for admins in some more places (Griatch)
|
||||
- [Fix][pull3420]: Refactor Clothing contrib's inventory command align with
|
||||
Evennia core's version (michaelfaith84, Griatch)
|
||||
- [Fix][issue3438]: Limiting search by tag didn't take search-string into
|
||||
account (Griatch)
|
||||
- [Fix][issue4311]: SSH connection caused a traceback in protocol (Griatch)
|
||||
- Fix: Resolve a bug when loading on-demand-handler data from database (Griatch)
|
||||
- Doc fixes (iLPdev, Griatch, CloudKeeper)
|
||||
|
||||
[pull3420]: https://github.com/evennia/evennia/pull/3420
|
||||
[issue3438]: https://github.com/evennia/evennia/issues/3438
|
||||
[issue3411]: https://github.com/evennia/evennia/issues/3411
|
||||
[issue3443]: https://github.com/evennia/evennia/issues/3443
|
||||
|
||||
## Evennia 3.2.0
|
||||
|
||||
Feb 25, 2024
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ with [EvEditor](./EvEditor.md), flipping pages in [EvMore](./EvMore.md) or using
|
|||
- [**@open**](CmdOpen) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_)
|
||||
- [**@py** [@!]](CmdPy) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
||||
- [**@reload** [@restart]](CmdReload) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
||||
- [**@reset** [@reboot]](CmdReset) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
||||
- [**@reset**](CmdReset) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
||||
- [**@scripts** [@script]](CmdScripts) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
||||
- [**@server** [@serverload]](CmdServerLoad) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
||||
- [**@service** [@services]](CmdService) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ evennia.contrib.tutorials.evadventure.tests
|
|||
:maxdepth: 6
|
||||
|
||||
evennia.contrib.tutorials.evadventure.tests.mixins
|
||||
evennia.contrib.tutorials.evadventure.tests.test_ai
|
||||
evennia.contrib.tutorials.evadventure.tests.test_characters
|
||||
evennia.contrib.tutorials.evadventure.tests.test_chargen
|
||||
evennia.contrib.tutorials.evadventure.tests.test_combat
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.tutorials.evadventure.tests.test\_ai
|
||||
===========================================================
|
||||
|
||||
.. automodule:: evennia.contrib.tutorials.evadventure.tests.test_ai
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -1,372 +1,232 @@
|
|||
"""
|
||||
NPC AI module for EvAdventure (WIP)
|
||||
|
||||
This implements a state machine for the NPCs, where it uses inputs from the game to determine what
|
||||
to do next. The AI works on the concept of being 'ticks', at which point, the AI will decide to move
|
||||
between different 'states', performing different 'actions' within each state until changing to
|
||||
another state. The odds of changing between states and performing actions are weighted, allowing for
|
||||
an AI agent to be more or less likely to perform certain actions.
|
||||
This implements a simple state machine for NPCs to follow.
|
||||
|
||||
The state machine is fed a dictionary of states and their transitions, and a dictionary of available
|
||||
actions to choose between.
|
||||
::
|
||||
The AIHandler class is stored on the NPC object and is queried by the game loop to determine what
|
||||
the NPC does next. This leads to the calling of one of the relevant state methods on the NPC, which
|
||||
is where the actual logic for the NPC's behaviour is implemented. Each state is responsible for
|
||||
switching to the next state when the conditions are met.
|
||||
|
||||
{
|
||||
"states": {
|
||||
"state1": {"action1": odds, "action2": odds, ...},
|
||||
"state2": {"action1": odds, "action2": odds, ...}, ...
|
||||
}
|
||||
"transition": {
|
||||
"state1": {"state2": "odds, "state3": odds, ...},
|
||||
"state2": {"state1": "odds, "state3": odds, ...}, ...
|
||||
}
|
||||
}
|
||||
The AIMixin class is a mixin that can be added to any object that needs AI. It provides the `.ai`
|
||||
reference to the AIHandler and a few basic `ai_*` methods for basic AI behaviour.
|
||||
|
||||
The NPC class needs to look like this:
|
||||
::
|
||||
|
||||
class NPC(DefaultCharacter):
|
||||
Example usage:
|
||||
|
||||
# ...
|
||||
```python
|
||||
from evennia import create_object
|
||||
from .npc import EvadventureNPC
|
||||
from .ai import AIMixin
|
||||
|
||||
@lazy_property
|
||||
def ai(self):
|
||||
return AIHandler(self)
|
||||
class MyMob(AIMixin, EvadventureNPC):
|
||||
pass
|
||||
|
||||
def ai_roam(self, action):
|
||||
# perform the action within the current state ai.state
|
||||
mob = create_object(MyMob, key="Goblin", location=room)
|
||||
|
||||
def ai_hunt(self, action):
|
||||
# etc
|
||||
mob.ai.set_state("patrol")
|
||||
|
||||
# tick the ai whenever needed
|
||||
mob.ai.run()
|
||||
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from evennia.utils import logger
|
||||
from evennia.utils.dbserialize import deserialize
|
||||
from evennia.utils.logger import log_trace
|
||||
from evennia.utils.utils import lazy_property
|
||||
|
||||
# Some example AI structures
|
||||
|
||||
EMOTIONAL_AI = {
|
||||
# Non-combat AI that has different moods for conversations
|
||||
"states": {
|
||||
"neutral": {"talk_neutral": 0.9, "change_state": 0.1},
|
||||
"happy": {"talk_happy": 0.9, "change_state": 0.1},
|
||||
"sad": {"talk_sad": 0.9, "change_state": 0.1},
|
||||
"angry": {"talk_angry": 0.9, "change_state": 0.1},
|
||||
}
|
||||
}
|
||||
|
||||
STATIC_AI = {
|
||||
# AI that just hangs around until attacked
|
||||
"states": {
|
||||
"idle": {"do_nothing": 1.0},
|
||||
"combat": {"attack": 0.9, "stunt": 0.1},
|
||||
}
|
||||
}
|
||||
|
||||
ROAM_AI = {
|
||||
# AI that roams around randomly, now and then stopping.
|
||||
"states": {
|
||||
"idle": {"do_nothing": 0.9, "change_state": 0.1},
|
||||
"roam": {
|
||||
"move_north": 0.1,
|
||||
"move_south": 0.1,
|
||||
"move_east": 0.1,
|
||||
"move_west": 0.1,
|
||||
"wait": 0.4,
|
||||
"change_state": 0.2,
|
||||
},
|
||||
"combat": {"attack": 0.9, "stunt": 0.05, "flee": 0.05},
|
||||
},
|
||||
"transitions": {
|
||||
"idle": {"roam": 0.5, "idle": 0.5},
|
||||
"roam": {"idle": 0.1, "roam": 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
HUNTER_AI = {
|
||||
"states": {
|
||||
"hunt_roam": {
|
||||
"move_north": 0.2,
|
||||
"move_south": 0.2,
|
||||
"move_east": 0.2,
|
||||
"move_west": 0.2,
|
||||
},
|
||||
"hunt_track": {
|
||||
"track_and_move": 0.9,
|
||||
"change_state": 0.1,
|
||||
},
|
||||
"combat": {"attack": 0.8, "stunt": 0.1, "other": 0.1},
|
||||
},
|
||||
"transitions": {
|
||||
# add a chance of the hunter losing its trail
|
||||
"hunt_track": {"hunt_roam": 1.0},
|
||||
},
|
||||
}
|
||||
from .enums import Ability
|
||||
|
||||
|
||||
class AIHandler:
|
||||
"""
|
||||
AIHandler class. This should be placed on the NPC object, and will handle the state machine,
|
||||
including transitions and actions.
|
||||
|
||||
Add to typeclass with @lazyproperty:
|
||||
|
||||
class NPC(DefaultCharacter):
|
||||
|
||||
ai_states = {...}
|
||||
|
||||
# ...
|
||||
|
||||
@lazyproperty
|
||||
def ai(self):
|
||||
return AIHandler(self)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
self.ai_state = obj.attributes.get("ai_state", category="ai_state", default="idle")
|
||||
|
||||
if hasattr(self, "ai_states"):
|
||||
# since we're not setting `force=True` here, we won't overwrite any existing /
|
||||
# customized dicts.
|
||||
self.add_aidict(self.ai_states)
|
||||
def set_state(self, state):
|
||||
self.ai_state = state
|
||||
self.obj.attributes.add("ai_state", state, category="ai_state")
|
||||
|
||||
def __str__(self):
|
||||
return f"AIHandler for {self.obj}. Current state: {self.state}"
|
||||
def get_state(self):
|
||||
return self.ai_state
|
||||
|
||||
@staticmethod
|
||||
def _normalize_odds(odds):
|
||||
def get_targets(self):
|
||||
"""
|
||||
Normalize odds to 1.0.
|
||||
Get a list of potential targets for the NPC to attack
|
||||
"""
|
||||
return [obj for obj in self.obj.location.contents if hasattr(obj, "is_pc") and obj.is_pc]
|
||||
|
||||
Args:
|
||||
odds (list): List of odds to normalize.
|
||||
Returns:
|
||||
list: Normalized list of odds.
|
||||
def get_traversable_exits(self, exclude_destination=None):
|
||||
return [
|
||||
exi
|
||||
for exi in self.obj.location.exits
|
||||
if exi.destination != exclude_destination and exi.access(self, "traverse")
|
||||
]
|
||||
|
||||
def random_probability(self, probabilities):
|
||||
"""
|
||||
Given a dictionary of probabilities, return the key of the chosen probability.
|
||||
|
||||
"""
|
||||
return [float(i) / sum(odds) for i in odds]
|
||||
|
||||
@staticmethod
|
||||
def _weighted_choice(choices, odds):
|
||||
"""
|
||||
Choose a random element from a list of choices, with odds.
|
||||
|
||||
Args:
|
||||
choices (list): List of choices to choose from. Unordered.
|
||||
odds (list): List of odds to choose from, matching the choices list. This
|
||||
can be a list of integers or floats, indicating priority. Have odds sum
|
||||
up to 100 or 1.0 to properly represent predictable odds.
|
||||
Returns:
|
||||
object: Randomly chosen element from choices.
|
||||
|
||||
"""
|
||||
if choices:
|
||||
return random.choices(choices, odds)[0]
|
||||
|
||||
@staticmethod
|
||||
def _weighted_choice_dict(choices):
|
||||
"""
|
||||
Choose a random element from a dictionary of choices, with odds.
|
||||
|
||||
Args:
|
||||
choices (dict): Dictionary of choices to choose from, with odds as values.
|
||||
Returns:
|
||||
object: Randomly chosen element from choices.
|
||||
|
||||
"""
|
||||
return AIHandler._weighted_choice(list(choices.keys()), list(choices.values()))
|
||||
|
||||
@staticmethod
|
||||
def _validate_ai_dict(aidict):
|
||||
"""
|
||||
Validate and normalize an AI dictionary.
|
||||
|
||||
Args:
|
||||
aidict (dict): AI dictionary to normalize.
|
||||
Returns:
|
||||
dict: Normalized AI dictionary.
|
||||
|
||||
"""
|
||||
if "states" not in aidict:
|
||||
raise ValueError("AI dictionary must contain a 'states' key.")
|
||||
|
||||
if "transitions" not in aidict:
|
||||
aidict["transitions"] = {}
|
||||
|
||||
# if we have no transitions, make sure we have a transition for each state set to 0
|
||||
for state in aidict["states"]:
|
||||
if state not in aidict["transitions"]:
|
||||
aidict["transitions"][state] = {}
|
||||
for state2 in aidict["states"]:
|
||||
if state2 not in aidict["transitions"][state]:
|
||||
aidict["transitions"][state][state2] = 0.0
|
||||
|
||||
# normalize odds
|
||||
for state, actions in aidict["states"].items():
|
||||
aidict["states"][state] = AIHandler._normalize_odds(list(actions.values()))
|
||||
for state, transitions in aidict["transitions"].items():
|
||||
aidict["transitions"][state] = AIHandler._normalize_odds(list(transitions.values()))
|
||||
|
||||
return aidict
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
Return the current state of the AI.
|
||||
|
||||
Returns:
|
||||
str: Current state of the AI.
|
||||
|
||||
"""
|
||||
return self.obj.attributes.get("ai_state", category="ai", default="idle")
|
||||
|
||||
@state.setter
|
||||
def state(self, value):
|
||||
"""
|
||||
Set the current state of the AI. This allows to force a state change, e.g. when starting
|
||||
combat.
|
||||
|
||||
Args:
|
||||
value (str): New state of the AI.
|
||||
|
||||
"""
|
||||
return self.obj.attributes.add("ai_state", category="ai")
|
||||
|
||||
@property
|
||||
def states(self):
|
||||
"""
|
||||
Return the states dictionary for the AI.
|
||||
|
||||
Returns:
|
||||
dict: States dictionary for the AI.
|
||||
|
||||
"""
|
||||
return self.obj.attributes.get("ai_states", category="ai", default={"idle": {}})
|
||||
|
||||
@states.setter
|
||||
def states(self, value):
|
||||
"""
|
||||
Set the states dictionary for the AI.
|
||||
|
||||
Args:
|
||||
value (dict): New states dictionary for the AI.
|
||||
|
||||
"""
|
||||
return self.obj.attributes.add("ai_states", value, category="ai")
|
||||
|
||||
@property
|
||||
def transitions(self):
|
||||
"""
|
||||
Return the transitions dictionary for the AI.
|
||||
|
||||
Returns:
|
||||
dict: Transitions dictionary for the AI.
|
||||
|
||||
"""
|
||||
return self.obj.attributes.get("ai_transitions", category="ai", default={"idle": []})
|
||||
|
||||
@transitions.setter
|
||||
def transitions(self, value):
|
||||
"""
|
||||
Set the transitions dictionary for the AI.
|
||||
|
||||
Args:
|
||||
value (dict): New transitions dictionary for the AI. This will be automatically
|
||||
normalized.
|
||||
|
||||
"""
|
||||
for state in value.keys():
|
||||
value[state] = dict(
|
||||
zip(value[state].keys(), self._normalize_odds(value[state].values()))
|
||||
)
|
||||
return self.obj.attributes.add("ai_transitions", value, category="ai")
|
||||
|
||||
def add_aidict(self, aidict, force=False):
|
||||
"""
|
||||
Add an AI dictionary to the AI handler, if one doesn't already exist.
|
||||
|
||||
Args:
|
||||
aidict (dict): AI dictionary to add.
|
||||
force (bool, optional): Force adding the AI dictionary, even if one already exists on
|
||||
this handler.
|
||||
|
||||
"""
|
||||
if not force and self.states and self.transitions:
|
||||
return
|
||||
|
||||
aidict = self._validate_ai_dict(aidict)
|
||||
self.states = aidict["states"]
|
||||
self.transitions = aidict["transitions"]
|
||||
|
||||
def adjust_transition_probability(self, state_start, state_end, odds):
|
||||
"""
|
||||
Adjust the transition probability between two states.
|
||||
|
||||
Args:
|
||||
state_start (str): State to start from.
|
||||
state_end (str): State to end at.
|
||||
odds (int): New odds for the transition.
|
||||
|
||||
Note:
|
||||
This will normalize the odds across the other transitions from the starting state.
|
||||
|
||||
"""
|
||||
transitions = deserialize(self.transitions)
|
||||
transitions[state_start][state_end] = odds
|
||||
transitions[state_start] = dict(
|
||||
zip(
|
||||
transitions[state_start].keys(),
|
||||
self._normalize_odds(transitions[state_start].values()),
|
||||
)
|
||||
r = random.random()
|
||||
# sort probabilities from higheest to lowest, making sure to normalize them 0..1
|
||||
prob_total = sum(probabilities.values())
|
||||
sorted_probs = sorted(
|
||||
((key, prob / prob_total) for key, prob in probabilities.items()),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
self.transitions = transitions
|
||||
total = 0
|
||||
for key, prob in sorted_probs:
|
||||
total += prob
|
||||
if r <= total:
|
||||
return key
|
||||
|
||||
def get_next_state(self):
|
||||
"""
|
||||
Get the next state for the AI.
|
||||
|
||||
Returns:
|
||||
str: Next state for the AI.
|
||||
|
||||
"""
|
||||
return self._weighted_choice_dict(self.transitions[self.state])
|
||||
|
||||
def get_next_action(self):
|
||||
"""
|
||||
Get the next action for the AI within the current state.
|
||||
|
||||
Returns:
|
||||
str: Next action for the AI.
|
||||
|
||||
"""
|
||||
return self._weighted_choice_dict(self.states[self.state])
|
||||
|
||||
def execute_ai(self):
|
||||
"""
|
||||
Execute the next ai action in the current state.
|
||||
|
||||
This assumes that each available state exists as a method on the object, named
|
||||
ai_<state_name>, taking an optional argument of the next action to perform. The method
|
||||
will itself update the state or transition weights through this handler.
|
||||
|
||||
Some states have in-built state transitions, via the special "change_state" action.
|
||||
|
||||
"""
|
||||
next_action = self.get_next_action()
|
||||
statechange = 0
|
||||
while next_action == "change_state":
|
||||
self.state = self.get_next_state()
|
||||
next_action = self.get_next_action()
|
||||
if statechange > 5:
|
||||
logger.log_err(f"AIHandler: {self.obj} got stuck in a state-change loop.")
|
||||
return
|
||||
|
||||
# perform the action
|
||||
def run(self):
|
||||
try:
|
||||
getattr(self.obj, f"ai_{self.state}")(next_action)
|
||||
except AttributeError:
|
||||
logger.log_err(f"AIHandler: {self.obj} has no ai_{self.state} method.")
|
||||
state = self.get_state()
|
||||
getattr(self.obj, f"ai_{state}")()
|
||||
except Exception:
|
||||
log_trace(f"AI error in {self.obj.name} (running state: {state})")
|
||||
|
||||
|
||||
class AIMixin:
|
||||
"""
|
||||
Mixin for adding AI to an Object. This is a simple state machine. Just add more `ai_*` methods
|
||||
to the object to make it do more things.
|
||||
|
||||
"""
|
||||
|
||||
# combat probabilities should add up to 1.0
|
||||
combat_probabilities = {
|
||||
"hold": 0.1,
|
||||
"attack": 0.9,
|
||||
"stunt": 0.0,
|
||||
"item": 0.0,
|
||||
"flee": 0.0,
|
||||
}
|
||||
|
||||
@lazy_property
|
||||
def ai(self):
|
||||
return AIHandler(self)
|
||||
|
||||
def ai_idle(self):
|
||||
pass
|
||||
|
||||
def ai_attack(self):
|
||||
pass
|
||||
|
||||
def ai_patrol(self):
|
||||
pass
|
||||
|
||||
def ai_flee(self):
|
||||
pass
|
||||
|
||||
|
||||
class IdleMobMixin(AIMixin):
|
||||
"""
|
||||
A simple mob that understands AI commands, but does nothing.
|
||||
|
||||
"""
|
||||
|
||||
def ai_idle(self):
|
||||
pass
|
||||
|
||||
|
||||
class AggressiveMobMixin(AIMixin):
|
||||
"""
|
||||
A simple aggressive mob that can roam, attack and flee.
|
||||
|
||||
"""
|
||||
|
||||
combat_probabilities = {
|
||||
"hold": 0.0,
|
||||
"attack": 0.85,
|
||||
"stunt": 0.05,
|
||||
"item": 0.0,
|
||||
"flee": 0.05,
|
||||
}
|
||||
|
||||
def ai_idle(self):
|
||||
"""
|
||||
Do nothing, but switch to attack state if a target is found.
|
||||
|
||||
"""
|
||||
if self.ai.get_targets():
|
||||
self.ai.set_state("attack")
|
||||
|
||||
def ai_attack(self):
|
||||
"""
|
||||
Manage the attack/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 "attack":
|
||||
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")
|
||||
|
||||
if not (targets := self.ai.get_targets()):
|
||||
self.ai.set_state("patrol")
|
||||
else:
|
||||
target = random.choice(targets)
|
||||
self.execute_cmd(f"attack {target.key}")
|
||||
|
||||
def ai_patrol(self):
|
||||
"""
|
||||
Patrol, moving randomly to a new room. If a target is found, switch to attack state.
|
||||
|
||||
"""
|
||||
if targets := self.ai.get_targets():
|
||||
self.ai.set_state("attack")
|
||||
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}")
|
||||
|
||||
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 patrol 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, patrol will allow for backing out
|
||||
self.ai.set_state("patrol")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from evennia.typeclasses.tags import TagProperty
|
|||
from evennia.utils.evmenu import EvMenu
|
||||
from evennia.utils.utils import make_iter
|
||||
|
||||
from .ai import AggressiveMobMixin
|
||||
from .characters import LivingMixin
|
||||
from .enums import Ability, WieldLocation
|
||||
from .objects import get_bare_hands
|
||||
|
|
@ -247,7 +248,7 @@ class EvAdventureShopKeeper(EvAdventureTalkativeNPC):
|
|||
)
|
||||
|
||||
|
||||
class EvAdventureMob(EvAdventureNPC):
|
||||
class EvAdventureMob(AggressiveMobMixin, EvAdventureNPC):
|
||||
"""
|
||||
Mob (mobile) NPC; this is usually an enemy.
|
||||
|
||||
|
|
@ -256,36 +257,6 @@ class EvAdventureMob(EvAdventureNPC):
|
|||
# chance (%) that this enemy will loot you when defeating you
|
||||
loot_chance = AttributeProperty(75, autocreate=False)
|
||||
|
||||
def ai_next_action(self, **kwargs):
|
||||
"""
|
||||
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.
|
||||
|
|
|
|||
47
evennia/contrib/tutorials/evadventure/tests/test_ai.py
Normal file
47
evennia/contrib/tutorials/evadventure/tests/test_ai.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""
|
||||
Test the ai module.
|
||||
|
||||
"""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from evennia import create_object
|
||||
from evennia.utils.test_resources import BaseEvenniaTest
|
||||
|
||||
from ..characters import EvAdventureCharacter
|
||||
from ..npcs import EvAdventureMob
|
||||
|
||||
|
||||
class TestAI(BaseEvenniaTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.npc = create_object(EvAdventureMob, key="Goblin", location=self.room1)
|
||||
self.pc = create_object(EvAdventureCharacter, key="Player", location=self.room1)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.npc.delete()
|
||||
|
||||
@patch("evennia.contrib.tutorials.evadventure.ai.random.random")
|
||||
@patch("evennia.contrib.tutorials.evadventure.ai.log_trace")
|
||||
def test_ai_methods(self, mock_log_trace, mock_random):
|
||||
self.assertEqual(self.npc.ai.get_state(), "idle")
|
||||
self.npc.ai.set_state("patrol")
|
||||
self.assertEqual(self.npc.ai.get_state(), "patrol")
|
||||
|
||||
self.assertEqual(self.npc.ai.get_targets(), [self.pc])
|
||||
self.assertEqual(self.npc.ai.get_traversable_exits(), [self.exit])
|
||||
|
||||
probs = {"hold": 0.1, "attack": 0.5, "flee": 0.4}
|
||||
mock_random.return_value = 0.3
|
||||
self.assertEqual(self.npc.ai.random_probability(probs), "attack")
|
||||
mock_random.return_value = 0.7
|
||||
self.assertEqual(self.npc.ai.random_probability(probs), "flee")
|
||||
mock_random.return_value = 0.95
|
||||
self.assertEqual(self.npc.ai.random_probability(probs), "hold")
|
||||
|
||||
def test_ai_run(self):
|
||||
self.npc.ai.set_state("patrol")
|
||||
self.assertEqual(self.npc.ai.get_state(), "patrol")
|
||||
|
||||
self.npc.ai.run()
|
||||
self.assertEqual(self.npc.ai.get_state(), "attack")
|
||||
Loading…
Add table
Add a link
Reference in a new issue