Start implementing ai-states for beginner tutorial

This commit is contained in:
Griatch 2024-03-10 23:11:17 +01:00
parent 7c70618326
commit 6e6ab208a6
7 changed files with 298 additions and 372 deletions

View file

@ -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

View file

@ -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_)

View file

@ -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

View file

@ -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:
```

View file

@ -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")

View file

@ -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.

View 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")