"""
NPC AI module for EvAdventure (WIP)
This implements a simple state machine for NPCs to follow.
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.
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.
Example usage:
```python
from evennia import create_object
from .npc import EvadventureNPC
from .ai import AIMixin
class MyMob(AIMixin, EvadventureNPC):
pass
mob = create_object(MyMob, key="Goblin", location=room)
mob.ai.set_state("patrol")
# tick the ai whenever needed
mob.ai.run()
```
"""
import random
from evennia.utils.logger import log_trace
from evennia.utils.utils import lazy_property
from .enums import Ability
[docs]class AIHandler:
[docs] def __init__(self, obj):
self.obj = obj
self.ai_state = obj.attributes.get("ai_state", category="ai_state", default="idle")
[docs] def set_state(self, state):
self.ai_state = state
self.obj.attributes.add("ai_state", state, category="ai_state")
[docs] def get_state(self):
return self.ai_state
[docs] def get_targets(self):
"""
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]
[docs] 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")
]
[docs] def random_probability(self, probabilities):
"""
Given a dictionary of probabilities, return the key of the chosen probability.
"""
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,
)
total = 0
for key, prob in sorted_probs:
total += prob
if r <= total:
return key
[docs] def run(self):
try:
state = self.get_state()
getattr(self.obj, f"ai_{state}")()
except Exception:
log_trace(f"AI error in {self.obj.name} (running state: {state})")
[docs]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,
}
[docs] @lazy_property
def ai(self):
return AIHandler(self)
[docs] def ai_idle(self):
pass
[docs] def ai_attack(self):
pass
[docs] def ai_patrol(self):
pass
[docs] def ai_flee(self):
pass
[docs]class IdleMobMixin(AIMixin):
"""
A simple mob that understands AI commands, but does nothing.
"""
[docs] def ai_idle(self):
pass
[docs]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,
}
[docs] 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")
[docs] 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}")
[docs] 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}")
[docs] 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")