Add WIP ai module for evadventure
This commit is contained in:
parent
b72e2e9394
commit
f7233f2459
1 changed files with 355 additions and 0 deletions
355
evennia/contrib/tutorials/evadventure/ai.py
Normal file
355
evennia/contrib/tutorials/evadventure/ai.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""
|
||||
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},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
# ...
|
||||
|
||||
@lazyproperty
|
||||
def ai(self):
|
||||
return AIHandler(self)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, obj):
|
||||
self.obj = obj
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Add an AI dictionary to the AI handler.
|
||||
|
||||
Args:
|
||||
aidict (dict): AI dictionary to add.
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
getattr(self.obj, f"ai_{self.state}")(next_action)
|
||||
Loading…
Add table
Add a link
Reference in a new issue