"""
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.
The state machine is fed a dictionary of states and their transitions, and a dictionary of available
actions to choose between.
::
{
"states": {
"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):
# ...
@lazy_property
def ai(self):
return AIHandler(self)
def ai_roam(self, action):
# perform the action within the current state ai.state
def ai_hunt(self, action):
# etc
"""
import random
from evennia.utils import logger
from evennia.utils.dbserialize import deserialize
# 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},
},
}
[docs]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)
"""
[docs] def __init__(self, obj):
self.obj = obj
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 __str__(self):
return f"AIHandler for {self.obj}. Current state: {self.state}"
@staticmethod
def _normalize_odds(odds):
"""
Normalize odds to 1.0.
Args:
odds (list): List of odds to normalize.
Returns:
list: Normalized list of odds.
"""
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")
[docs] 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"]
[docs] 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
[docs] 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])
[docs] 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])
[docs] 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:
getattr(self.obj, f"ai_{self.state}")(next_action)
except AttributeError:
logger.log_err(f"AIHandler: {self.obj} has no ai_{self.state} method.")