Wrote the AI beginner tutorial lesson. Started procedural dungeon lesson

This commit is contained in:
Griatch 2024-03-24 01:38:19 +01:00
parent 05ab1c2a9b
commit 8085aa30db
10 changed files with 787 additions and 228 deletions

View file

@ -24,7 +24,7 @@ class MyMob(AIMixin, EvadventureNPC):
mob = create_object(MyMob, key="Goblin", location=room)
mob.ai.set_state("patrol")
mob.ai.set_state("roam")
# tick the ai whenever needed
mob.ai.run()
@ -39,27 +39,42 @@ from evennia.utils.logger import log_trace
from evennia.utils.utils import lazy_property
from .enums import Ability
from .utils import random_probability
class AIHandler:
attribute_name = "ai_state"
attribute_category = "ai_state"
def __init__(self, obj):
self.obj = obj
self.ai_state = obj.attributes.get("ai_state", category="ai_state", default="idle")
self.ai_state = obj.attributes.get(self.attribute_name,
category=self.attribute_category,
default="idle")
def set_state(self, state):
self.ai_state = state
self.obj.attributes.add("ai_state", state, category="ai_state")
self.obj.attributes.add(self.attribute_name, state, category=self.attribute_category)
def get_state(self):
return self.ai_state
def get_targets(self):
"""
Get a list of potential targets for the NPC to attack
Get a list of potential targets for the NPC to combat.
"""
return [obj for obj in self.obj.location.contents if hasattr(obj, "is_pc") and obj.is_pc]
def get_traversable_exits(self, exclude_destination=None):
"""
Get a list of exits that the NPC can traverse. Optionally exclude a destination.
Args:
exclude_destination (Object, optional): Exclude exits with this destination.
"""
return [
exi
for exi in self.obj.location.exits
@ -70,8 +85,11 @@ class AIHandler:
"""
Given a dictionary of probabilities, return the key of the chosen probability.
Args:
probabilities (dict): A dictionary of probabilities, where the key is the action and the
value is the probability of that action.
"""
r = random.random()
# sort probabilities from higheest to lowest, making sure to normalize them 0..1
prob_total = sum(probabilities.values())
sorted_probs = sorted(
@ -79,10 +97,12 @@ class AIHandler:
key=lambda x: x[1],
reverse=True,
)
rand = random.random()
total = 0
for key, prob in sorted_probs:
total += prob
if r <= total:
if rand <= total:
return key
def run(self):
@ -98,135 +118,10 @@ 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.
In the tutorial, the handler is added directly to the Mob class, to avoid going into the details
of multiple inheritance. In a real game, you would probably want to use a mixin like this.
"""
# 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

@ -8,9 +8,9 @@ from evennia import DefaultCharacter
from evennia.typeclasses.attributes import AttributeProperty
from evennia.typeclasses.tags import TagProperty
from evennia.utils.evmenu import EvMenu
from evennia.utils.utils import make_iter
from evennia.utils.utils import lazy_property, make_iter
from .ai import AggressiveMobMixin
from .ai import AIHandler
from .characters import LivingMixin
from .enums import Ability, WieldLocation
from .objects import get_bare_hands
@ -248,14 +248,103 @@ class EvAdventureShopKeeper(EvAdventureTalkativeNPC):
)
class EvAdventureMob(AggressiveMobMixin, EvAdventureNPC):
class EvAdventureMob(EvAdventureNPC):
"""
Mob (mobile) NPC; this is usually an enemy.
"""
# change this to make the mob more or less likely to perform different actions
combat_probabilities = {
"hold": 0.0,
"attack": 0.85,
"stunt": 0.05,
"item": 0.0,
"flee": 0.05,
}
# chance (%) that this enemy will loot you when defeating you
loot_chance = AttributeProperty(75, autocreate=False)
@lazy_property
def ai(self):
return AIHandler(self)
def ai_idle(self):
"""
Do nothing.
"""
pass
def ai_combat(self):
"""
Manage the combat/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 "combat":
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")
elif not (targets := self.ai.get_targets()):
self.ai.set_state("roam")
else:
target = random.choice(targets)
self.execute_cmd(f"attack {target.key}")
def ai_roam(self):
"""
roam, moving randomly to a new room. If a target is found, switch to combat state.
"""
if targets := self.ai.get_targets():
self.ai.set_state("combat")
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 roam 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, roam will allow for backing out
self.ai.set_state("roam")
def at_defeat(self):
"""
@ -263,55 +352,3 @@ class EvAdventureMob(AggressiveMobMixin, EvAdventureNPC):
"""
self.at_death()
def at_do_loot(self, looted):
"""
Called when mob gets to loot a PC.
"""
if dice.roll("1d100") > self.loot_chance:
# don't loot
return
if looted.coins:
# looter prefer coins
loot = dice.roll("1d20")
if looted.coins < loot:
self.location.msg_location(
"$You(looter) loots $You() for all coin!",
from_obj=looted,
mapping={"looter": self},
)
else:
self.location.msg_location(
"$You(looter) loots $You() for |y{loot}|n coins!",
from_obj=looted,
mapping={"looter": self},
)
elif hasattr(looted, "equipment"):
# go through backpack, first usable, then wieldable, wearable items
# and finally stuff wielded
stealable = looted.equipment.get_usable_objects_from_backpack()
if not stealable:
stealable = looted.equipment.get_wieldable_objects_from_backpack()
if not stealable:
stealable = looted.equipment.get_wearable_objects_from_backpack()
if not stealable:
stealable = [looted.equipment.slots[WieldLocation.SHIELD_HAND]]
if not stealable:
stealable = [looted.equipment.slots[WieldLocation.HEAD]]
if not stealable:
stealable = [looted.equipment.slots[WieldLocation.ARMOR]]
if not stealable:
stealable = [looted.equipment.slots[WieldLocation.WEAPON_HAND]]
if not stealable:
stealable = [looted.equipment.slots[WieldLocation.TWO_HANDS]]
stolen = looted.equipment.remove(choice(stealable))
stolen.location = self
self.location.msg_location(
"$You(looter) steals {stolen.key} from $You()!",
from_obj=looted,
mapping={"looter": self},
)

View file

@ -25,23 +25,23 @@ class TestAI(BaseEvenniaTest):
@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.npc.ai.set_state("roam")
self.assertEqual(self.npc.ai.get_state(), "roam")
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}
probs = {"hold": 0.1, "combat": 0.5, "flee": 0.4}
mock_random.return_value = 0.3
self.assertEqual(self.npc.ai.random_probability(probs), "attack")
self.assertEqual(self.npc.ai.random_probability(probs), "combat")
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.set_state("roam")
self.assertEqual(self.npc.ai.get_state(), "roam")
self.npc.ai.run()
self.assertEqual(self.npc.ai.get_state(), "attack")
self.assertEqual(self.npc.ai.get_state(), "combat")

View file

@ -3,6 +3,8 @@ Various utilities.
"""
import random
_OBJ_STATS = """
|c{key}|n
Value: ~|y{value}|n coins{carried}
@ -15,6 +17,7 @@ Attacks using |w{attack_type_name}|n against |w{defense_type_name}|n
Damage roll: |w{damage_roll}|n""".strip()
def get_obj_stats(obj, owner=None):
"""
Get a string of stats about the object.
@ -50,3 +53,27 @@ def get_obj_stats(obj, owner=None):
defense_type_name=defense_type.value if defense_type else "No defense",
damage_roll=getattr(obj, "damage_roll", "None"),
)
def random_probability(self, probabilities):
"""
Given a dictionary of probabilities, return the key of the chosen probability.
Args:
probabilities (dict): A dictionary of probabilities, where the key is the action and the
value is the probability of that action.
"""
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

View file

@ -24,17 +24,9 @@ from evennia.server.signals import SIGNAL_EXIT_TRAVERSED
from evennia.typeclasses.attributes import ModelAttributeBackend, NickHandler
from evennia.typeclasses.models import TypeclassBase
from evennia.utils import ansi, create, funcparser, logger, search
from evennia.utils.utils import (
class_from_module,
dbref,
is_iter,
iter_to_str,
lazy_property,
make_iter,
compress_whitespace,
to_str,
variable_from_module,
)
from evennia.utils.utils import (class_from_module, compress_whitespace, dbref,
is_iter, iter_to_str, lazy_property,
make_iter, to_str, variable_from_module)
_INFLECT = inflect.engine()
_MULTISESSION_MODE = settings.MULTISESSION_MODE