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
|
# 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
|
## Evennia 3.2.0
|
||||||
|
|
||||||
Feb 25, 2024
|
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_)
|
- [**@open**](CmdOpen) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _Building_)
|
||||||
- [**@py** [@!]](CmdPy) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
- [**@py** [@!]](CmdPy) (cmdset: [AccountCmdSet](AccountCmdSet), help-category: _System_)
|
||||||
- [**@reload** [@restart]](CmdReload) (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_)
|
- [**@scripts** [@script]](CmdScripts) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
||||||
- [**@server** [@serverload]](CmdServerLoad) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
- [**@server** [@serverload]](CmdServerLoad) (cmdset: [CharacterCmdSet](CharacterCmdSet), help-category: _System_)
|
||||||
- [**@service** [@services]](CmdService) (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
|
:maxdepth: 6
|
||||||
|
|
||||||
evennia.contrib.tutorials.evadventure.tests.mixins
|
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_characters
|
||||||
evennia.contrib.tutorials.evadventure.tests.test_chargen
|
evennia.contrib.tutorials.evadventure.tests.test_chargen
|
||||||
evennia.contrib.tutorials.evadventure.tests.test_combat
|
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)
|
NPC AI module for EvAdventure (WIP)
|
||||||
|
|
||||||
This implements a state machine for the NPCs, where it uses inputs from the game to determine what
|
This implements a simple state machine for NPCs to follow.
|
||||||
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.
|
|
||||||
|
|
||||||
The state machine is fed a dictionary of states and their transitions, and a dictionary of available
|
The AIHandler class is stored on the NPC object and is queried by the game loop to determine what
|
||||||
actions to choose between.
|
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.
|
||||||
|
|
||||||
{
|
The AIMixin class is a mixin that can be added to any object that needs AI. It provides the `.ai`
|
||||||
"states": {
|
reference to the AIHandler and a few basic `ai_*` methods for basic AI behaviour.
|
||||||
"state1": {"action1": odds, "action2": odds, ...},
|
|
||||||
"state2": {"action1": odds, "action2": odds, ...}, ...
|
|
||||||
}
|
|
||||||
"transition": {
|
|
||||||
"state1": {"state2": "odds, "state3": odds, ...},
|
|
||||||
"state2": {"state1": "odds, "state3": odds, ...}, ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
class MyMob(AIMixin, EvadventureNPC):
|
||||||
def ai(self):
|
pass
|
||||||
return AIHandler(self)
|
|
||||||
|
|
||||||
def ai_roam(self, action):
|
mob = create_object(MyMob, key="Goblin", location=room)
|
||||||
# perform the action within the current state ai.state
|
|
||||||
|
|
||||||
def ai_hunt(self, action):
|
mob.ai.set_state("patrol")
|
||||||
# etc
|
|
||||||
|
# tick the ai whenever needed
|
||||||
|
mob.ai.run()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from evennia.utils import logger
|
from evennia.utils.logger import log_trace
|
||||||
from evennia.utils.dbserialize import deserialize
|
from evennia.utils.utils import lazy_property
|
||||||
|
|
||||||
# Some example AI structures
|
from .enums import Ability
|
||||||
|
|
||||||
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},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AIHandler:
|
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):
|
def __init__(self, obj):
|
||||||
self.obj = obj
|
self.obj = obj
|
||||||
|
self.ai_state = obj.attributes.get("ai_state", category="ai_state", default="idle")
|
||||||
|
|
||||||
if hasattr(self, "ai_states"):
|
def set_state(self, state):
|
||||||
# since we're not setting `force=True` here, we won't overwrite any existing /
|
self.ai_state = state
|
||||||
# customized dicts.
|
self.obj.attributes.add("ai_state", state, category="ai_state")
|
||||||
self.add_aidict(self.ai_states)
|
|
||||||
|
|
||||||
def __str__(self):
|
def get_state(self):
|
||||||
return f"AIHandler for {self.obj}. Current state: {self.state}"
|
return self.ai_state
|
||||||
|
|
||||||
@staticmethod
|
def get_targets(self):
|
||||||
def _normalize_odds(odds):
|
|
||||||
"""
|
"""
|
||||||
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:
|
def get_traversable_exits(self, exclude_destination=None):
|
||||||
odds (list): List of odds to normalize.
|
return [
|
||||||
Returns:
|
exi
|
||||||
list: Normalized list of odds.
|
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]
|
r = random.random()
|
||||||
|
# sort probabilities from higheest to lowest, making sure to normalize them 0..1
|
||||||
@staticmethod
|
prob_total = sum(probabilities.values())
|
||||||
def _weighted_choice(choices, odds):
|
sorted_probs = sorted(
|
||||||
"""
|
((key, prob / prob_total) for key, prob in probabilities.items()),
|
||||||
Choose a random element from a list of choices, with odds.
|
key=lambda x: x[1],
|
||||||
|
reverse=True,
|
||||||
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()),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.transitions = transitions
|
total = 0
|
||||||
|
for key, prob in sorted_probs:
|
||||||
|
total += prob
|
||||||
|
if r <= total:
|
||||||
|
return key
|
||||||
|
|
||||||
def get_next_state(self):
|
def run(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
|
|
||||||
try:
|
try:
|
||||||
getattr(self.obj, f"ai_{self.state}")(next_action)
|
state = self.get_state()
|
||||||
except AttributeError:
|
getattr(self.obj, f"ai_{state}")()
|
||||||
logger.log_err(f"AIHandler: {self.obj} has no ai_{self.state} method.")
|
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.evmenu import EvMenu
|
||||||
from evennia.utils.utils import make_iter
|
from evennia.utils.utils import make_iter
|
||||||
|
|
||||||
|
from .ai import AggressiveMobMixin
|
||||||
from .characters import LivingMixin
|
from .characters import LivingMixin
|
||||||
from .enums import Ability, WieldLocation
|
from .enums import Ability, WieldLocation
|
||||||
from .objects import get_bare_hands
|
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.
|
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
|
# chance (%) that this enemy will loot you when defeating you
|
||||||
loot_chance = AttributeProperty(75, autocreate=False)
|
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):
|
def at_defeat(self):
|
||||||
"""
|
"""
|
||||||
Mobs die right away when defeated, no death-table rolls.
|
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