From 8085aa30db7dde4b4f0f92d6428745441344b73d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Mar 2024 01:38:19 +0100 Subject: [PATCH] Wrote the AI beginner tutorial lesson. Started procedural dungeon lesson --- .../Part3/Beginner-Tutorial-AI.md | 395 +++++++++++++++++- .../Beginner-Tutorial-Combat-Turnbased.md | 4 +- .../Part3/Beginner-Tutorial-Combat-Twitch.md | 6 +- .../Part3/Beginner-Tutorial-Dungeon.md | 223 +++++++++- .../Part3/Beginner-Tutorial-NPCs.md | 18 +- evennia/contrib/tutorials/evadventure/ai.py | 163 ++------ evennia/contrib/tutorials/evadventure/npcs.py | 151 ++++--- .../tutorials/evadventure/tests/test_ai.py | 14 +- .../contrib/tutorials/evadventure/utils.py | 27 ++ evennia/objects/objects.py | 14 +- 10 files changed, 787 insertions(+), 228 deletions(-) diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md index 53911ed9b8..115c61faf4 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md @@ -1,5 +1,394 @@ # NPC and monster AI -```{warning} -This part of the Beginner tutorial is still being developed. -``` \ No newline at end of file +```{sidebar} Artificial Intelligence sounds complex +The term "Artificial Intelligence" can sound daunting. It evokes images of supercomputers, machine learning, neural networks and large language models. For our use case though, you can get something that feels pretty 'intelligent' by just using a few if-statements. +``` +Not every entity in the game are controlled by a player. NPCs and enemies need to be controlled by the computer - that is, we need to give them artificial intelligence (AI). + +For our game we will implement a type of AI called a 'state machine'. It means that the entity (like an NPC or mob) is always in a given 'state'. An example of a state could be 'idle', 'roaming' or 'attacking'. +At regular intervals, the AI entity will be 'ticked' by Evennia. This 'tick' starts with an evaluation which determines if the entity should switch to another state, or stay and perform one (or more) actions inside the current state. + +```{sidebar} Mobs and NPC +'Mob' is short for 'Mobile' and is a common MUD term for an entity that can move between rooms. The term is usually used for aggressive enemies. A Mob is also an 'NPC' (Non-Player Character), but the latter term is often used for more peaceful entities, like shopkeeprs and quest givers. +``` + +For example, if a mob in a 'roaming' state comes upon a player character, it may switch into the 'attack' state. In combat it could move between different combat actions, and if it survives combat it would go back to its 'roaming' state. + +The AI can be 'ticked' on different time scales depending on how your game works. For example, while a mob is moving, they might automatically move from room to room every 20 seconds. But once it enters turn-based combat (if you use that), the AI will 'tick' only on every turn. + +## Our requirements + +```{sidebar} Shopkeepers and quest givers +NPC shopkeepers and quest givers will be assumed to always be in the 'idle' state in our game - the functionality of talking to or shopping from them will be explored in a future lesson. +``` + +For this tutorial game, we'll need AI entities to be able to be in the following states: + +- _Idle_ - don't do anything, just stand around. +- _Roam_ - move from room to room. It's important that we add the ability to limit where the AI can roam to. For example, if we have non-combat areas we want to be able to [lock](../../../Components/Locks.md) all exits leading into those areas so aggressive mods doesn't walk into them. +- _Combat_ - initiate and perform combat with PCs. This state will make use of the [Combat Tutorial](./Beginner-Tutorial-Combat-Base.md) to randomly select combat actions (turn-based or tick-based as appropriately). +- _Flee_ - this is like _Roam_ except the AI will move so as to avoid entering rooms with PCs, if possible. + +We will organize the AI code like this: +- `AIHandler` this will be a handler stored as `.ai` on the AI entity. It is responsible for storing the AI's state. To 'tick' the AI, we run `.ai.run()`. How often we crank the wheels of the AI this way we leave up to other game systems. +- `.ai_` methods on the NPC/Mob class - when the `ai.run()` method is called, it is responsible for finding a method named like its current state (e.g. `.ai_combat` if we are in the _combat_ state). Having methods like this makes it easy to add new states - just add a new method named appropriately and the AI now knows how to handle that state! + +## The AIHandler + +```{{sidebar}} +You can find an AIHandler implemented in `evennia/contrib/tutorials`, in [evadventure/tests/test_ai.py](evennia.contrib.tutorials.evadventure.ai) +``` +This is the core logic for managing AI states. Create a new file `evadventure/ai.py`. + +```{code-block} python +:linenos: +:emphasize-lines: 10,11-13,16,23 +# in evadventure/ai.py + +from evennia.logger import log_trace + +class AIHandler: + attribute_name = "ai_state" + attribute_category = "ai_state" + + def __init__(self, obj): + self.obj = obj + 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(self.attribute_name, state, category=self.attribute_category) + + def get_state(self): + return self.ai_state + + 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})") + + +``` + +The AIHandler is an example of an [Object Handler](../../Tutorial-Persistent-Handler.md). This is a design style that groups all functionality together. To look-ahead a little, this handler will be added to the object like this: +```{sidebar} lazy_property +This is an Evennia [@decorator](https://realpython.com/primer-on-python-decorators/) that makes it so that the handler won't be initialized until someone actually tries to access `obj.ai` for the first time. On subsequent calls, the already initialized handler is returned. This is a very useful performance optimization when you have a lot of objects and also important for the functionality of handlers. +``` + +```python +# just an example, don't put this anywhere yet + +from evennia.utils import lazy_property +from evadventure.ai import AIHandler + +class MyMob(SomeParent): + + @lazy_property + class ai(self): + return AIHandler(self) +``` + +So in short, accessing the `.ai` property will initialize an instance of `AIHandler`, to which we pass `self` (the current object). In the `AIHandler.__init__` we take this input and store it as `self.obj` (**lines 10-13**). This way the handler can always operate on the entity it's "sitting on" by accessing `self.obj`. The `lazy_property` makes sure that this initialization only happens once per server reload. + +More key functionality: + +- **Line 11**: We (re)load the AI state by accessing `self.obj.attributes.get()`. This loads a database [Attribute](../../../Components/Attributes.md) with a given name and category. If one is not (yet) saved, return "idle". Note that we must access `self.obj` (the NPC/mob) since that is the only thing with access to the database. +- **Line 16**: In the `set_state` method we force the handler to switch to a given state. When we do, we make sure to save it to the database as well, so its state survives a reload. But we also store it in `self.ai_state` so we don't need to hit the database on every fetch. +- **line 23**: The `getattr` function is an in-built Python function for getting a named property on an object. This allows us to, based on the current state, call a method `ai_` defined on the NPC/mob. We must wrap this call in a `try...except` block to properly handle errors in the AI method. Evennia's `log_trace` will make sure to log the error, including its traceback for debugging. + +### More helpers on the AI handler + +It's also convenient to put a few helpers on the AIHandler. This makes them easily available from inside the `ai_` methods, callable as e.g. `self.ai.get_targets()`. + +```{code-block} python +:linenos: +:emphasize-lines: 41,42,47,49 +# in evadventure/ai.py + +# ... +import random + +class AIHandler: + + # ... + + def get_targets(self): + """ + 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 + 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. + + Args: + probabilities (dict): A dictionary of probabilities, where the key is the action and the + value is the probability of that action. + + """ + # 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, + ) + rand = random.random() + total = 0 + for key, prob in sorted_probs: + total += prob + if rand <= total: + return key +``` + +```{sidebar} Locking exits +The 'traverse' lock is the default lock-type checked by Evennia before allowing something to pass through an exit. Since only PCs have the `is_pc` property, we could lock down exits to _only_ allow entities with the property to pass through. + +In game: + + lock north = traverse:attr(is_pc, True) + +Or in code: + + exit_obj.locks.add( + "traverse:attr(is_ic, True)") + +See [Locks](../../../Components/Locks.md) for a lot more information about Evennia locks. +``` +- `get_targets` checks if any of the other objects in the same location as the `is_pc` property set on their typeclass. For simplicity we assume Mobs will only ever attack PCs (no monster in-fighting!). +- `get_traversable_exits` fetches all valid exits from the current location, excluding those with a provided destination _or_ those which doesn't pass the "traverse" access check. +- `get_random_probability` takes a dict `{action: probability, ...}`. This will randomly select an action, but the higher the probability, the more likely it is that it will be picked. We will use this for the combat state later, to allow different combatants to more or less likely to perform different combat actions. This algorithm uses a few useful Python tools: + - **Line 41**: Remember `probabilities` is a `dict` `{key: value, ...}`, where the values are the probabilities. So `probabilities.values()` gets us a list of only the probabilities. Running `sum()` on them gets us the total sum of those probabilities. We need that to normalize all probabilities between 0 and 1.0 on the line below. + - **Lines 42-46**: Here we create a new iterable of tuples `(key, prob/prob_total)`. We sort them using the Python `sorted` helper. The `key=lambda x: x[1]` means that we sort on the second element of each tuple (the probability). The `reverse=True` means that we'll sort from highest probability to lowest. + - **Line 47**:The `random.random()` call generates a random value between 0 and 1. + - **Line 49**: Since the probabilities are sorted from highest to lowest, we loop over them until we find the first one fitting in the random value - this is the action/key we are looking for. + - To give an example, if you have a `probability` input of `{"attack": 0.5, "defend": 0.1, "idle": 0.4}`, this would become a sorted iterable `(("attack", 0.5), ("idle", 0.4), ("defend": 0.1))`, and if `random.random()` returned 0.65, the outcome would be "idle". If `random.random()` returned `0.90`, it would be "defend". That is, this AI entity would attack 50% of the time, idle 40% and defend 10% of the time. + + +## Adding AI to an entity + +All we need to add AI-support to a game entity is to add the AI handler and a bunch of `.ai_statename()` methods onto that object's typeclass. + +We already sketched out NPCs and Mob typeclasses back in the [NPC tutorial](Beginner-Tutorial_NPCs). Open `evadventure/npcs.py` and expand the so-far empty `EvAdventureMob` class. + +```python +# in evadventure/npcs.py + +# ... + +from evennia.utils import lazy_property +from .ai import AIHandler + +# ... + +class EvAdventureMob(EvAdventureNPC): + + @lazy_property + def ai(self): + return AIHandler(self) + + def ai_idle(self): + pass + + def ai_roam(self): + pass + + def ai_roam(self): + pass + + def ai_combat(self): + pass + + def ai_flee(self): + pass + +``` + +All the remaining logic will go into each state-method. + +### Idle state + +In the idle state the mob does nothing, so we just leave the `ai_idle` method as it is - with just an empty `pass` in it. This means that it will also not attack PCs in the same room - but if a PC attacks it, we must make sure to force it into a combat state (otherwise it will be defenseless). + +### Roam state + +In this state the mob should move around from room to room until it finds PCs to attack. + +```python +# in evadventure/npcs.py + +# ... + +import random + +class EvAdventureMob(EvAdventureNPC): + + # ... + + 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}") +``` + +Every time the AI is ticked, this method will be called. It will first check if there are any valid targets in the room (using the `get_targets()` helper we made on the `AIHandler`). If so, we switch to the `combat` state and immediately call the `attack` command to initiate/join combat (see the [Combat tutorial](./Beginner-Tutorial-Combat-Base.md)). + +If no target is found, we get a list of traversible exits (exits that fail the `traverse` lock check is already excluded from this list). Using Python's in-bult `random.choice` function we grab a random exit from that list and moves through it by its name. + +### Flee state + +Flee is similar to _Roam_ except the the AI never tries to attack anything and will make sure to not return the way it came. + +```python +# in evadventure/npcs.py + +# ... + +class EvAdventureMob(EvAdventureNPC): + + # ... + + 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") + +``` + +We store the `past_room` in an Attribute "past_room" on ourselves and make sure to exclude it when trying to find random exits to traverse to. + +If we end up in a dead end we switch to _Roam_ mode so that it can get back out (and also start attacking things again). So the effect of this is that the mob will flee in terror as far as it can before 'calming down'. + +### Combat state + +While in the combat state, the mob will use one of the combat systems we've designed (either [twitch-based combat](./Beginner-Tutorial-Combat-Twitch.md) or [turn-based combat](./Beginner-Tutorial-Combat-Turnbased.md)). This means that every time the AI ticks, and we are in the combat state, the entity needs to perform one of the available combat actions, _hold_, _attack_, _do a stunt_, _use an item_ or _flee_. + +```{code-block} python +:linenos: +:emphasize-lines: 7,22,24,25 +# in evadventure/npcs.py + +# ... + +class EvAdventureMob(EvAdventureNPC): + + combat_probabilities = { + "hold": 0.0, + "attack": 0.85, + "stunt": 0.05, + "item": 0.0, + "flee": 0.05, + } + + # ... + + 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}") + +``` + +- **Lines 7-13**: This dict describe how likely the mob is to perform a given combat action. By just modifying this dictionary we can easily creating mobs that behave very differently, like using items more or being more prone to fleeing. You can also turn off certain action entirely - by default his mob never "holds" or "uses items". +- **Line 22**: If we are in combat, a `CombadHandler` should be initialized on us, available as as `self.ndb.combathandler` (see the [base combat tutorial](./Beginner-Tutorial-Combat-Base.md)). +- **Line 24**: The `combathandler.get_sides()` produces the allies and enemies for the one passed to it. +- **Line 25**: Now that `random_probability` method we created earlier in this lesson becomes handy! + +The rest of this method just takes the randomly chosen action and performs the required operations to queue it as a new action with the `CombatHandler`. For simplicity, we only use stunts to boost our allies, not to hamper our enemies. + +Finally, if we are not currently in combat and there are no enemies nearby, we switch to roaming - otherwise we start another fight! + +## Unit Testing + +```{{sidebar}} +Find an example of AI tests in [evennia/contrib/tutorials/tests/test_ai.py](evennia.contrib.tutorials.evadventure.tests.test_ai). +``` +> Create a new file `evadventure/tests/test_ai.py`. + +Testing the AI handler and mob is straightforward if you have followed along with previous lessons. Create an `EvAdventureMob` and test that calling the various ai-related methods and handlers on it works as expected. A complexity is to mock the output from `random` so that you always get the same random result to compare against. We leave the implementation of AI tests as an extra exercise for the reader. + +## Conclusions + +You can easily expand this simple system to make Mobs more 'clever'. For example, instead of just randomly decide which action to take in combat, the mob could consider more factors - maybe some support mobs could use stunts to pave the way for their heavy hitters or use health potions when badly hurt. + +It's also simple to add a 'hunt' state, where mobs check adjoining rooms for targets before moving there. + +And while implementing a functional game AI system requires no advanced math or machine learning techniques, there's of course no limit to what kind of advanced things you could add if you really wanted to! + diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md index dce9944b69..c456cd51d2 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Turnbased.md @@ -1219,7 +1219,7 @@ Our turnbased combat system is complete! ## Testing ```{sidebar} -See [evennia/contrib/tutorials/evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) +See an example tests in `evennia/contrib/tutorials`, in [evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) ``` Unit testing of the Turnbased combat handler is straight forward, you follow the process of earlier lessons to test that each method on the handler returns what you expect with mocked inputs. @@ -1237,7 +1237,7 @@ Unit testing the code is not enough to see that combat works. We need to also ma - An item (like a potion) we can `use`. ```{sidebar} -You can find an example batch-code script in [evennia/contrib/tutorials/evadventure/batchscripts/turnbased_combat_demo.py](github:evennia/contrib/tutorials/evadventure/batchscripts/turnbased_combat_demo.py) +You can find an example combat batch-code script in `evennia/contrib/tutorials/evadventure/`, in [batchscripts/turnbased_combat_demo.py](github:evennia/contrib/tutorials/evadventure/batchscripts/turnbased_combat_demo.py) ``` In [The Twitch combat lesson](./Beginner-Tutorial-Combat-Twitch.md) we used a [batch-command script](../../../Components/Batch-Command-Processor.md) to create the testing environment in game. This runs in-game Evennia commands in sequence. For demonstration purposes we'll instead use a [batch-code script](../../../Components/Batch-Code-Processor.md), which runs raw Python code in a repeatable way. A batch-code script is much more flexible than a batch-command script. diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.md index c803ffec4d..90fc994457 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Twitch.md @@ -62,7 +62,7 @@ You can change up your strategy by performing other actions (like drinking a pot ## General principle ```{sidebar} -An example of an implemented Twitch combat system can be found in [evennia/contrib/tutorials/evadventure/combat_twitch.py](evennia.contrib.tutorials.evadventure.combat_twitch). +An example of an implemented Twitch combat system can be found in `evennia/contrib/tutorials`, in [evadventure/combat_twitch.py](evennia.contrib.tutorials.evadventure.combat_twitch). ``` Here is the general design of the Twitch-based combat handler: @@ -874,7 +874,7 @@ Now that we have the Look command set, we can finish the Twitch combat handler. ## Unit Testing ```{sidebar} -See [evennia/contrib/tutorials/evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for an example of a full suite of combat tests. +For examples of unit tests, see `evennia/contrib/tutorials`, in [evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for an example of a full suite of combat tests. ``` > Create `evadventure/tests/test_combat.py` (if you don't already have it). @@ -920,7 +920,7 @@ Inside the test, we use the `self.call()` method to explicitly fire the Command ## A small combat test ```{sidebar} -You can find an example batch-command script in [evennia/contrib/tutorials/evadventure/batchscripts/twitch_combat_demo.ev](github:evennia/contrib/tutorials/evadventure/batchscripts/twitch_combat_demo.ev) +You can find an example batch-command script at `evennia/contrib/tutorials/evadventure`, in [batchscripts/twitch_combat_demo.ev](github:evennia/contrib/tutorials/evadventure/batchscripts/twitch_combat_demo.ev) ``` Showing that the individual pieces of code works (unit testing) is not enough to be sure that your combat system is actually working. We need to test all the pieces _together_. This is often called _functional testing_. While functional testing can also be automated, wouldn't it be fun to be able to actually see our code in action? diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.md index b0854e1c06..1d22554db5 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Dungeon.md @@ -1,5 +1,224 @@ -# Dynamically generated Dungeon +# Procedurally generated Dungeon + +The rooms that we discussed in the [lesson about Rooms](./Beginner-Tutorial-Rooms.md) are all _manually_ generated. That is, a human builder would have to sit down and spawn each room manually, either in-game or using code. + +In this lesson we'll explore _procedural_ generation of the rooms making up our game's underground dungeon. Procedural means that its rooms are spawned automatically and semi-randomly as players explore, creating a different dungeon layout every time. + +## Design Concept + +This describes how the procedural generation should work at a high level. It's important to understand this before we start writing code. + +We will assume our dungeon exists on a 2D plane (x,y, no z directions). We will only use N,E,S,W compass directions, but there is no reason this design couldn't work with SE, NW etc, except that this could make it harder for the player to visualize. More possible directions also make it more likely to produce collisions and one-way exits (see below). + +This design is pretty simple, but just by playing with some of its settings, it can produce very different-feeling dungeon systems. + +### The starting room + +The idea is that all players will descend down a well to get to the start of the dungeon. The bottom of the well is a statically created room that won't change. + +```{code-block} +:caption: Starting room + + Branch N + ▲ + │ + ┌────────┼────────┐ + │ │n │ + │ ▼ │ + │ │ + │ e│ +Branch W ◄─┼─► up▲ ◄─┼─► Branch E1 + │w │ + │ │ + │ ▲ │ + │ │s │ + └────────┼────────┘ + │ + ▼ + Branch S +``` + +The magic happens when you choose one of the exits from this room (except the one leading you back to the surface). Let's assume a PC descends down to the start room and moves `east`: + +- The first person to go east will spawn a new "Dungeon branch" (Branch E1 in the diagram). This is a separate "instance" of dungeon compared to what would spawn if moving through any of the other exits. Rooms spawned within one dungeon branch will never overlap with that of another dungeon branch. +- A timer starts. While this timer is active, everyone going `east` will end up in Branch E1. This allows for players to team up and collaborate to take on a branch. +- After the timer runs out, everyone going `east` will instead end up in a _new_ Branch E2. This is a new branch that has no overlap with Branch E1. +- PCs in Branches E1 and E2 can always retreat `west` back to the starting room, but after the timer runs out this is now a one-way exit - they won't be able to return to their old branches if they do. + +### Generating new branch rooms + +Each branch is managed by an branch _orchestrator_. The orchestrator tracks the layout of rooms belonging to this branch on an (X, Y) coordinate grid. + +```{code-block} +:caption: Creating the eastern branch and its first room + ? + ▲ + │ +┌─────────┐ ┌────┼────┐ +│ │ │A │ │ +│ │ │ PC │ +│ start◄─┼───┼─► is ──┼──►? +│ │ │ here │ +│ │ │ │ │ +└─────────┘ └────┼────┘ + │ + ▼ +``` + +The start room is always at coordinate `(0, 0)`. + +A dungeon room is only created when actually moving to it. In the above example, the PC moved `east` from the start room, which initiated a new dungeon branch with its own branch orchestrator. The orchestrator also created a new room (room `A`) at coordinate `(1,0)`. In this case it (randomly) seeded this room with three exits `north`, `east` and `south`. +Since this branch was just created, the exit back to the start room is still two-way. + +This is the procedure the orchestrator follows when spawning a new room: + +- It always creates an exit back to the room we came from. +- It checks how many unexplored exits we have in the dungeon right now. That is, how many exits we haven't yet traversed. This number must never be zero unless we want a dungeon that can be 'finished'. The maximum number of unexplored exits open at any given time is a setting we can experiment with. A small max number leads to linear dungeon, a bigger number makes the dungeon sprawling and maze-like. +- Outgoing exits (exits not leading back to where we came) are generated with the following rules: + - Randomly create between 0 and the number of outgoing exits allowed by the room and the branches' current budget of allowed open unexplored exits. + - Create 0 outgoing exits (a dead-end) only if this would leave at least one unexplored exit open somewhere in the dungeon branch. + - Do _not_ create an exit that would connect the exit to a previously generated room (so we prefer exits leading to new places rather than back to old ones) + - If a previously created exit end up pointing to a newly created room, this _is_ allowed, and is the only time a one-way exit will happen (example below). All other exits are always two-way exits. This also presents the only small chance of closing out a dungeon with no way to proceed but to return to the start. + - Never create an exit back to the start room (e.g. from another direction). The only way to get back to the start room is by back tracking. + +In the following examples, we assume the maximum number of unexplored exits allowed open at any time is set to 4. + +```{code-block} +:caption: After four steps in the eastern dungeon branch + ? + ▲ + │ +┌─────────┐ ┌────┼────┐ +│ │ │A │ │ +│ │ │ │ +│ start◄─┼───┼─ ──┼─►? +│ │ │ ▲ │ +│ │ │ │ │ +└─────────┘ └────┼────┘ + │ + ┌────┼────┐ ┌─────────┐ ┌─────────┐ + │B │ │ │C │ │D │ + │ ▼ │ │ │ │ PC │ + ?◄──┼─ ◄─┼───┼─► ◄─┼───┼─► is │ + │ │ │ │ │ here │ + │ │ │ │ │ │ + └─────────┘ └─────────┘ └─────────┘ +``` + +1. PC moves `east` from the start room. A new room `A` (coordinate `(1, 0)` ) is created. After a while the exit back to the start room becomes a one-way exit. The branch can have at most 4 unexplored exits, and the orchestrator randomly adds three additional exits out of room `A`. +2. PC moves `south`. A new room `B` (`(1,-1)`) is created, with two random exits, which is as many as the orchetrator is allowed to create at this time (4 are now open). It also always creates an exit back to the previous room (`A`) +3. PC moves `east` (coordinate (`(2, -1)`). A new room `C` is created. The orchestrator already has 3 exits unexplored, so it can only add one exit our of this room. +4. PC moves `east` (`(3, -1)`). While the orchestrator still has a budget of one exit, it knows there are other unexplored exits elsewhere, and is allowed to randomly create 0 exits. This is a dead end. The PC must go back and explore another direction. + +Let's change the dungeon a bit to do another example: + +```{code-block} +:caption: Looping around + ? + ▲ + │ +┌─────────┐ ┌────┼────┐ +│ │ │A │ │ +│ │ │ │ +│ start◄─┼───┼─ ──┼──►? +│ │ │ ▲ │ +│ │ │ │ │ ? +└─────────┘ └────┼────┘ ▲ + │ │ + ┌────┼────┐ ┌────┼────┐ + │B │ │ │C │ │ + │ ▼ │ │ PC │ + ?◄──┼─ ◄─┼───┼─► is │ + │ │ │ here │ + │ │ │ │ + └─────────┘ └─────────┘ + +``` + +In this example the PC moved `east`, `south`, `east` but the exit out of room `C` is leading north, into a coordinate where `A` already has an exit pointing to. Going `north` here leads to the following: + +```{code-block} +:caption: Creation of a one-way exit + ? + ▲ + │ +┌─────────┐ ┌────┼────┐ ┌─────────┐ +│ │ │A │ │ │D PC │ +│ │ │ │ │ is │ +│ start◄─┼───┼─ ──┼───┼─► here │ +│ │ │ ▲ │ │ ▲ │ +│ │ │ │ │ │ │ │ +└─────────┘ └────┼────┘ └────┼────┘ + │ │ + ┌────┼────┐ ┌────┼────┐ + │B │ │ │C │ │ + │ ▼ │ │ ▼ │ + ?◄──┼─ ◄─┼───┼─► │ + │ │ │ │ + │ │ │ │ + └─────────┘ └─────────┘ +``` + +As the PC moves `north`, the room `D` is created at `(2,0)`. + +While `C` to `D` get a two-way exit as normal, this creates a one-way exit from `A` to `D`. + +Whichever exit leads to actually creating the room gets the two-way exit, so if the PC had walked back from `C` and created room `D` by going `east` from room `A`, then the one-way exit would be from room `C` instead. + +> If the maximum allowed number of open unexplored exits is small, this case is the only situation where it's possible to 'finish' the dungeon (having no more unexplored exits to follow). We accept this as a case where the PCs just have to turn back. + +```{code-block} +:caption: Never link back to start room + ? + ▲ + │ +┌─────────┐ ┌────┼────┐ ┌─────────┐ +│ │ │A │ │ │D │ +│ │ │ │ │ │ +│ start◄─┼───┼─ ──┼───┼─► │ +│ │ │ ▲ │ │ ▲ │ +│ │ │ │ │ │ │ │ +└─────────┘ └────┼────┘ └────┼────┘ + │ │ +┌─────────┐ ┌────┼────┐ ┌────┼────┐ +│E │ │B │ │ │C │ │ +│ PC │ │ ▼ │ │ ▼ │ +│ is ◄─┼───┼─► ◄─┼───┼─► │ +│ here │ │ │ │ │ +│ │ │ │ │ │ +└─────────┘ └─────────┘ └─────────┘ +``` + +Here the PC moved `west` from room `B` creating room `E` at `(0, -1)`. + +The orchestrator never creates a link back to the start room, but it _could_ have created up to two new exits `west` and/or `south`. Since there's still an unexplored exit `north` from room `A`, the orchestrator is also allowed to randomly assign 0 exits, which is what it did here. + +The PC needs to backtrack and go `north` from `A` to continue exploring this dungeon branch. + +### Making the dungeon dangerous + +A dungeon would not be interesting without peril! There needs to be monsters to slay, puzzles to solve and treasure to be had. + +When PCs first enters a room, that room is marked as `not clear`. While a room is not cleared, the PCs _cannot use any of the unexplored exits out of that room_. They _can_ still retreat back the way they came unless they become locked in combat, in which case they have to flee from that first. + +Once PCs have overcome the challenge of the room (and probably earned some reward), will it change to `clear` . A room can auto-clear if it is spawned empty or has no challenge meant to block the PCs (like a written hint for a puzzle elsewhere). + +Note that clear/non-clear only relates to the challenge associated with that room. Roaming monsters (see the [AI tutorial](./Beginner-Tutorial-AI.md)) can lead to combat taking place in previously 'cleared' rooms. + +### Difficulty scaling + +```{sidebar} Risk and reward +The concept of dungeon depth/difficulty works well together with limited resources. If healing is limited to what can be carried, this leads to players having to decide if they want to risk push deeper or take their current spoils and retreat back to the surface to recover. +``` + +The "difficulty" of the dungeon is measured by the "depth" PCs have delved to. This is given as the _radial distance_ from the start room, rounded down, found by the good old [Pythagorean theorem](https://en.wikipedia.org/wiki/Pythagorean_theorem): + + depth = int(math.sqrt(x**2 + y**2)) + +So if you are in room `(1, 1)` you are at difficulty 1. Conversely at room coordinate `(4,-5)` the difficulty is 6. Increasing depth should lead to tougher challenges but greater rewards. + +## Implementation ```{warning} -This part of the Beginner tutorial is still being developed. +TODO: This part is TODO. ``` \ No newline at end of file diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md index 612a74022c..bba390a9cd 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-NPCs.md @@ -80,14 +80,14 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter): """ self.hp = self.hp_max self.tags.add("npcs", category="group") - - def ai_next_action(self, **kwargs): - """ - The system should regularly poll this method to have - the NPC do their next AI action. - - """ - pass + + +class EvAdventureMob(EvAdventureNPC): + """ + Mob(ile) NPC to be used for enemies. + + """ + ``` - **Line 9**: By use of _multiple inheritance_ we use the `LinvingMixin` we created in the [Character lesson](./Beginner-Tutorial-Characters.md). This includes a lot of useful methods, such as showing our 'hurt level', methods to use to heal, hooks to call when getting attacked, hurt and so on. We can re-use all of those in upcoming NPC subclasses. @@ -96,8 +96,8 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter): - **Lines 17, 18**: The `morale` and `allegiance` are _Knave_ properties determining how likely the NPC is to flee in a combat situation and if they are hostile or friendly. - **Line 19**: The `is_idle` Attribute is a useful property. It should be available on all NPCs and will be used to disable AI entirely. - **Line 59**: We make sure to tag NPCs. We may want to group different NPCs together later, for example to have all NPCs with the same tag respond if one of them is attacked. -- **Line 61**: The `ai_next_action` is a method we prepare for the system to be able to ask the NPC 'what do you want to do next?'. In it we will add all logic related to the artificial intelligence of the NPC - such as walking around, attacking and performing other actions. +We make an empty subclass `EvAdventureMob`. A 'mob' (short for 'mobile') is a common MUD term for NPCs that can move around on their own. We will in the future use this class to represent enemies in the game. We will get back to this class [in the lesson about adding AI][Beginner-Tutoroal-AI]. ## Testing diff --git a/evennia/contrib/tutorials/evadventure/ai.py b/evennia/contrib/tutorials/evadventure/ai.py index 41165b9bee..90338e968c 100644 --- a/evennia/contrib/tutorials/evadventure/ai.py +++ b/evennia/contrib/tutorials/evadventure/ai.py @@ -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") diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index 8f0181fe85..755ce70940 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -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}, - ) diff --git a/evennia/contrib/tutorials/evadventure/tests/test_ai.py b/evennia/contrib/tutorials/evadventure/tests/test_ai.py index 8ebb71d03a..f96b4fb478 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_ai.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_ai.py @@ -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") diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py index d39e786e7d..6fb12de138 100644 --- a/evennia/contrib/tutorials/evadventure/utils.py +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -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 diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index b9c7e97ba6..97b5849224 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -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