diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 4f4d92d266..6a3c49036a 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -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 diff --git a/docs/source/Components/Default-Commands.md b/docs/source/Components/Default-Commands.md index 8a19fc8523..19b0d0a878 100644 --- a/docs/source/Components/Default-Commands.md +++ b/docs/source/Components/Default-Commands.md @@ -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_) diff --git a/docs/source/api/evennia.contrib.tutorials.evadventure.tests.md b/docs/source/api/evennia.contrib.tutorials.evadventure.tests.md index 78fb3c7812..3c4466534e 100644 --- a/docs/source/api/evennia.contrib.tutorials.evadventure.tests.md +++ b/docs/source/api/evennia.contrib.tutorials.evadventure.tests.md @@ -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 diff --git a/docs/source/api/evennia.contrib.tutorials.evadventure.tests.test_ai.md b/docs/source/api/evennia.contrib.tutorials.evadventure.tests.test_ai.md new file mode 100644 index 0000000000..1d2bf3956e --- /dev/null +++ b/docs/source/api/evennia.contrib.tutorials.evadventure.tests.test_ai.md @@ -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: + +``` \ No newline at end of file diff --git a/evennia/contrib/tutorials/evadventure/ai.py b/evennia/contrib/tutorials/evadventure/ai.py index 37dd3d5ce4..41165b9bee 100644 --- a/evennia/contrib/tutorials/evadventure/ai.py +++ b/evennia/contrib/tutorials/evadventure/ai.py @@ -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_, 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") diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index ddb484e121..8f0181fe85 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -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. diff --git a/evennia/contrib/tutorials/evadventure/tests/test_ai.py b/evennia/contrib/tutorials/evadventure/tests/test_ai.py new file mode 100644 index 0000000000..8ebb71d03a --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/tests/test_ai.py @@ -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")