Merge branch 'main' of https://github.com/evennia/evennia into editor_echo

This commit is contained in:
Chiizujin 2024-03-31 15:26:18 +11:00
commit 4a1ff3deeb
18 changed files with 711 additions and 273 deletions

View file

@ -5,13 +5,31 @@
- [Feature][pull3421]: New `utils.compress_whitespace` utility used with
default object's `.format_appearance` to make it easier to overload without
adding line breaks in hook returns. (InspectorCaracal)
- [Feature][pull3458]: New `sethelp/category` switch to change a help topic's
category after it was created (chiizujin)
- [Fix][pull3446]: Use plural ('no apples') instead of singular ('no apple') in
`get_numbered_name` for better grammatical form (InspectorCaracal)
- Doc: Added Beginner Tutorial lessons for AI and Procedural dungeon (Griatch)
- [Fix][pull3453]: Object aliases not showing in search multi-match
disambiguation display (chiizujin)
- [Fix][pull3455]: `sethelp/edit <topic>` without a `= text` created a `None`
entry that would lose the edit. (chiiziujin)
- [Fix][pull3456]: `format_grid` utility used for `help` command caused commands
to disappear for wider client widths (chiizujin)
- [Fix][pull3457]: Help topic categories with different case would appear as
duplicates (chiizujin)
- [Fix][pull3454]: Traceback in crafting contrib's `recipe.msg`
(InspectorCaracal)
- Doc: Added Beginner Tutorial lessons for AI, Quests and Procedural dungeon (Griatch)
- Doc fixes (Griatch, InspectorCaracal)
[pull3421]: https://github.com/evennia/evennia/pull/3421
[pull3446]: https://github.com/evennia/evennia/pull/3446
[pull3453]: https://github.com/evennia/evennia/pull/3453
[pull3455]: https://github.com/evennia/evennia/pull/3455
[pull3456]: https://github.com/evennia/evennia/pull/3456
[pull3457]: https://github.com/evennia/evennia/pull/3457
[pull3458]: https://github.com/evennia/evennia/pull/3458
[pull3454]: https://github.com/evennia/evennia/pull/3454
## Evennia 4.0.0

View file

@ -5,12 +5,28 @@
- [Feature][pull3421]: New `utils.compress_whitespace` utility used with
default object's `.format_appearance` to make it easier to overload without
adding line breaks in hook returns. (InspectorCaracal)
- [Feature][pull3458]: New `sethelp/category` switch to change a help topic's
category after it was created (chiizujin)
- [Fix][pull3446]: Use plural ('no apples') instead of singular ('no apple') in
`get_numbered_name` for better grammatical form (InspectorCaracal)
- [Fix][pull3453]: Object aliases not showing in search multi-match
disambiguation display (chiizujin)
- [Fix][pull3455]: `sethelp/edit <topic>` without a `= text` created a `None`
entry that would lose the edit. (chiiziujin)
- [Fix][pull3456]: `format_grid` utility used for `help` command caused commands
to disappear for wider client widths (chiizujin)
- [Fix][pull3457]: Help topic categories with different case would appear as
duplicates (chiizujin)
- Doc: Added Beginner Tutorial lessons for AI, Quests and Procedural dungeon (Griatch)
- Doc fixes (Griatch, InspectorCaracal)
[pull3421]: https://github.com/evennia/evennia/pull/3421
[pull3446]: https://github.com/evennia/evennia/pull/3446
[pull3453]: https://github.com/evennia/evennia/pull/3453
[pull3455]: https://github.com/evennia/evennia/pull/3455
[pull3456]: https://github.com/evennia/evennia/pull/3456
[pull3457]: https://github.com/evennia/evennia/pull/3457
[pull3458]: https://github.com/evennia/evennia/pull/3458
## Evennia 4.0.0

View file

@ -40,6 +40,8 @@ You can find an AIHandler implemented in `evennia/contrib/tutorials`, in [evadve
```
This is the core logic for managing AI states. Create a new file `evadventure/ai.py`.
> Create a new file `evadventure/ai.py`.
```{code-block} python
:linenos:
:emphasize-lines: 10,11-13,16,23

View file

@ -16,7 +16,7 @@ We will design a base combat system that supports both styles.
> Create a new module `evadventure/combat_base.py`
```{sidebar}
In [evennia/contrib/tutorials/evadventure/combat_base.py](evennia.contrib.tutorials.evadventure.combat_base) you'll find a complete implementation of the base combat module.
Under `evennia/contrib/tutorials/evadventure/`, in [combat_base.py](evennia.contrib.tutorials.evadventure.combat_base) you'll find a complete implementation of the base combat module.
```
Our "Combat Handler" will handle the administration around combat. It needs to be _persistent_ (even is we reload the server your combat should keep going).
@ -718,7 +718,7 @@ We rely on the [Equipment handler](./Beginner-Tutorial-Equipment.md) we created
> Create a module `evadventure/tests/test_combat.py`.
```{sidebar}
See [evennia/contrib/tutorials/evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for ready-made combat unit tests.
Look under `evennia/contrib/tutorials/evadventure/`, in [tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for ready-made combat unit tests.
```
Unit testing the combat base classes can seem impossible because we have not yet implemented most of it. We can however get very far by the use of [Mocks](https://docs.python.org/3/library/unittest.mock.html). The idea of a Mock is that you _replace_ a piece of code with a dummy object (a 'mock') that can be called to return some specific value.

View file

@ -80,7 +80,7 @@ The advantage of using a menu is that you have all possible actions directly ava
## General Principle
```{sidebar}
An example of an implemented Turnbased combat system can be found in [evennia/contrib/tutorials/evadventure/combat_turnbased.py](evennia.contrib.tutorials.evadventure.combat_turnbased).
An example of an implemented Turnbased combat system can be found under `evennia/contrib/tutorials/evadventure/`, in [combat_turnbased.py](evennia.contrib.tutorials.evadventure.combat_turnbased).
```
Here is the general principle of the Turnbased combat handler:

View file

@ -2,8 +2,401 @@
A _quest_ is a common feature of games. From classic fetch-quests like retrieving 10 flowers to complex quest chains involving drama and intrigue, quests need to be properly tracked in our game.
A quest follows a specific development:
A quest follows a specific development:
1. The quest is _started_. This normally involves the player accepting the quest, from a quest-giver, job board or other source. But the quest could also be thrust on the player ("save the family from the burning house before it collapses!")
2. A quest may consist of one or more 'steps'. Each step has its own set of finish conditions.
3. At suitable times the quest is _checked_. This could happen on a timer or when trying to 'hand in' the quest. When checking, the current 'step' is checked against its finish conditions. If ok, that step is closed and the next step is checked until it either hits a step that is not yet complete, or there are no more steps, in which case the entire quest is complete.
2. Once a quest has been accepted and assigned to a character, it is either either `Started` (that is, 'in progress'), `Abandoned`, `Failed` or `Complete`.
3. A quest may consist of one or more 'steps'. Each step has its own set of finish conditions.
4. At suitable times the quest's _progress_ is checked. This could happen on a timer or when trying to 'hand in' the quest. When checking, the current 'step' is checked against its finish conditions. If ok, that step is closed and the next step is checked until it either hits a step that is not yet complete, or there are no more steps, in which case the entire quest is complete.
```{sidebar}
An example implementation of quests is found under `evennia/contrib/tutorials`, in [evadvanture/quests.py](evennia.contrib.tutorials.evadventure.quests).
```
To represent quests in code, we need
- A convenient flexible way to code how we check the status and current steps of the quest. We want this scripting to be as flexible as possible. Ideally we want to be able to code the quests's logic in full Python.
- Persistence. The fact that we accepted the quest, as well as its status and other flags must be saved in the database and survive a server reboot.
We'll accomplish this using two pieces of Python code:
- `EvAdventureQuest`: A Python class with helper methods that we can call to check current quest status, figure if a given quest-step is complete or not. We will create and script new quests by simply inheriting from this base class and implement new methods on it in a standardized way.
- `EvAdventureQuestHandler` will sit 'on' each Character as `character.quests`. It will hold all `EvAdventureQuest`s that the character is or has been involved in. It is also responsible for storing quest state using [Attributes](../../../Components/Attributes.md) on the Character.
## The Quest Handler
> Create a new module `evadventure/quests.py`.
We saw the implementation of an on-object handler back in the [lesson about NPC and monster AI](./Beginner-Tutorial-AI.md#the-aihandler) (the `AIHandler`).
```{code-block} python
:linenos:
:emphasize-lines: 9,10,11,14-18,21,24-28
# in evadventure/quests.py
class EvAdventureQuestHandler:
quest_storage_attribute_key = "_quests"
quest_storage_attribute_category = "evadventure"
def __init__(self, obj):
self.obj = obj
self.quest_classes = {}
self.quests = {}
self._load()
def _load(self):
self.quest_classes = self.obj.attributes.get(
self.quest_storage_attribute_key,
category=self.quest_storage_attribute_category,
default={},
)
# instantiate all quests
for quest_key, quest_class in self.quest_classes.items():
self.quests[quest_key] = quest_class(self.obj)
def _save(self):
self.obj.attributes.add(
self.quest_storage_attribute_key,
self.quest_classes,
category=self.quest_storage_attribute_category,
)
def get(self, quest_key):
return self.quests.get(quest_key)
def all(self):
return list(self.quests.values())
def add(self, quest_class):
self.quest_classes[quest_class.key] = quest_class
self.quests[quest_class.key] = quest_class(self.obj)
self._save()
def remove(self, quest_key):
quest = self.quests.pop(quest_key, None)
self.quest_classes.pop(quest_key, None)
self.quests.pop(quest_key, None)
self._save()
```
```{sidebar} Persistent handler pattern
Persistent handlers are commonly used throughout Evennia. You can read more about them in the [Making a Persistent object Handler](../../Tutorial-Persistent-Handler.md) tutorial.
```
- **Line 9**: We know that the quests themselves will be Python classes inheriting from `EvAdventureQuest` (which we haven't created yet). We will store those classes in `self.quest_classes` on the handler. Note that there is a difference between a class and an _instance_ of a class! The class cannot hold any _state_ on its own, such as the status of that quest is for this particular character. The class only holds python code.
- **Line 10**: We set aside another property on the handler - `self.quest` This is dictionary that will hold `EvAdventureQuest` _instances_.
- **Line 11**: Note that we call the `self._load()` method here, this loads up data from the database whenever this handler is accessed.
- **Lines 14-18**: We use `self.obj.attributes.get` to fetch an [Attribute](../../../Components/Attributes.md) on the Character named `_quests` and with a category of `evadventure`. If it doesn't exist yet (because we never started any quests), we just return an empty dict.
- **Line 21**: Here we loop over all the classes and instantiate them. We haven't defined how these quest-classes look yet, but by instantiating them with `self.obj` (the Character) we should be covered - from the Character class the quest will be able to get to everything else (this handler itself will be accessible as `obj.quests` from that quest instance after all).
- **Line 24**: Here we do the corresponding save operation.
The rest of the handler are just access methods for getting, adding and removing quests from the handler. We make one assumption in those code, namely that the quest class has a property `.key` being the unique quest-name.
This is how it would be used in practice:
```python
# in some questing code
from evennia import search_object
from evadventure import quests
class EvAdventureSuperQuest(quests.EvAdventureQuest):
key = "superquest"
# quest implementation here
def start_super_quest(character):
character.quests.add(EvAdventureSuperQuest)
```
```{sidebar} What can be saved in Attributes?
For more details, see [the Attributes documentation](../../../Components/Attributes.md#what-types-of-data-can-i-save-in-an-attribute) on the matter.
```
We chose to store classes and not instances of classes above. The reason for this has to do with what can be stored in a database `Attribute` - one limitation of an Attribute is that we can't save a class instance _with other database entities baked inside it_. If we saved quest instances as-is, it's highly likely they'd contain database entities 'hidden' inside them - a reference to the Character, maybe to objects required for the quest to be complete etc. Evennia would fail trying to save that data.
Instead we store only the classes, instantiate those classes with the Character, and let the quest store its state flags separately, like this:
```python
# in evadventure/quests.py
class EvAdventureQuestHandler:
# ...
quest_data_attribute_template = "_quest_data_{quest_key}"
quest_data_attribute_category = "evadventure"
# ...
def save_quest_data(self, quest_key):
quest = self.get(quest_key)
if quest:
self.obj.attributes.add(
self.quest_data_attribute_template.format(quest_key=quest_key),
quest.data,
category=self.quest_data_attribute_category,
)
def load_quest_data(self, quest_key):
return self.obj.attributes.get(
self.quest_data_attribute_template.format(quest_key=quest_key),
category=self.quest_data_attribute_category,
default={},
)
```
This works the same as the `_load` and `_save` methods, except it fetches a property `.data` (this will be a `dict`) on the quest instance and save it. As long as we make sure to call these methods from the quest the quest whenever that `.data` property is changed, all will be well - this is because Attributes know how to properly analyze a `dict` to find and safely serialize any database entities found within.
Our handler is ready. We created the `EvAdventureCharacter` class back in the [Character lesson](./Beginner-Tutorial-Characters.md) - let's add quest-support to it.
```python
# in evadventure/characters.py
# ...
from evennia.utils import lazy_property
from evadventure.quests import EvAdventureQuestHandler
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
# ...
@lazy_property
def quests(self):
return EvAdventureQuestHandler(self)
# ...
```
We also need a way to represent the quests themselves though!
## The Quest class
```{code-block} python
:linenos:
:emphasize-lines: 7,12,13,34-36
# in evadventure/quests.py
# ...
class EvAdventureQuest:
key = "base-quest"
desc = "Base quest"
start_step = "start"
def __init__(self, quester):
self.quester = quester
self.data = self.questhandler.load_quest_data(self.key)
self._current_step = self.get_data("current_step")
if not self.current_step:
self.current_step = self.start_step
def add_data(self, key, value):
self.data[key] = value
self.questhandler.save_quest_data(self.key)
def get_data(self, key, default=None):
return self.data.get(key, default)
def remove_data(self, key):
self.data.pop(key, None)
self.questhandler.save_quest_data(self.key)
@property
def questhandler(self):
return self.quester.quests
@property
def current_step(self):
return self._current_step
@current_step.setter
def current_step(self, step_name):
self._current_step = step_name
self.add_data("current_step", step_name)
```
- **Line 7**: Each class must have a `.key` property unquely identifying the quest. We depend on this in the quest-handler.
- **Line 12**: `quester` (the Character) is passed into this class when it is initiated inside `EvAdventureQuestHandler._load()`.
- **Line 13**: We load the quest data into `self.data` directly using the `questhandler.load_quest-data` method (which in turn loads it from an Attribute on the Character). Note that the `.questhandler` property is defined on **lines 34-36** as a shortcut to get to the handler.
The `add/get/remove_data` methods are convenient wrappers for getting data in and out of the database using the matching methods on the handler. When we implement a quest we should prefer to use `.get_data`, `add_data` and `remove_data` over manipulating `.data` directly, since the former will make sure to save said that to the database automatically.
The `current_step` tracks the current quest 'step' we are in; what this means is up to each Quest. We set up convenient properties for setting the `current_state` and also make sure to save it in the data dict as "current_step".
The quest can have a few possible statuses: "started", "completed", "abandoned" and "failed". We create a few properties and methods for easily control that, while saving everything under the hood:
```python
# in evadventure/quests.py
# ...
class EvAdventureQuest:
# ...
@property
def status(self):
return self.get_data("status", "started")
@status.setter
def status(self, value):
self.add_data("status", value)
@property
def is_completed(self):
return self.status == "completed"
@property
def is_abandoned(self):
return self.status == "abandoned"
@property
def is_failed(self):
return self.status == "failed"
def complete(self):
self.status = "completed"
def abandon(self):
self.status = "abandoned"
def fail(self):
self.status = "failed"
```
So far we have only added convenience functions for checking statuses. How will the actual "quest" aspect of this work?
What will happen when the system wants to check the progress of the quest, is that it will call a method `.progress()` on this class. Similarly, to get help for the current step, it will call a method `.help()`
```python
start_step = "start"
# help entries for quests (could also be methods)
help_start = "You need to start first"
help_end = "You need to end the quest"
def progress(self, *args, **kwargs):
getattr(self, f"step_{self.current_step}")(*args, **kwargs)
def help(self, *args, **kwargs):
if self.status in ("abandoned", "completed", "failed"):
help_resource = getattr(self, f"help_{self.status}",
f"You have {self.status} this quest.")
else:
help_resource = getattr(self, f"help_{self.current_step}", "No help available.")
if callable(help_resource):
# the help_* methods can be used to dynamically generate help
return help_resource(*args, **kwargs)
else:
# normally it's just a string
return str(help_resource)
```
```{sidebar} What's with the *args, **kwargs?
These are optional, but allow you to pass extra information into your quest-check. This could be very powerful if you want to add extra context to determine if a quest-step is currently complete or not.
```
Calling the `.progress(*args, **kwargs)` method will call a method named `step_<current_step>(*args, **kwargs)` on this class. That is, if we are on the _start_ step, the method called will be `self.step_start(*args, **kwargs)`. Where is this method? It has not been implemented yet! In fact, it's up to us to implement methods like this for each quest. By just adding a correctly added method, we will easily be able to add more steps to a quest.
Similarly, calling `.help(*args, **kwargs)` will try to find a property `help_<current_step>`. If this is a callable, it will be called as for example `self.help_start(*args, **kwargs)`. If it is given as a string, then the string will be returned as-is and the `*args, **kwargs` will be ignored.
### Example quest
```python
# in some quest module, like world/myquests.py
from evadventure.quests import EvAdventureQuest
class ShortQuest(EvAdventureQuest):
key = "simple-quest"
desc = "A very simple quest."
def step_start(self, *args, **kwargs):
"""Example step!"""
self.quester.msg("Quest started!")
self.current_step = "end"
def step_end(self, *args, **kwargs):
if not self.is_completed:
self.quester.msg("Quest ended!")
self.complete()
```
This is a very simple quest that will resolve on its own after two `.progress()` checks. Here's the full life cycle of this quest:
```python
# in some module somewhere, using evennia shell or in-game using py
from evennia import search_object
from world.myquests import ShortQuest
character = search_object("MyCharacterName")[0]
character.quests.add(ShortQuest)
# this will echo "Quest started!" to character
character.quests.get("short-quest").progress()
# this will echo "Quest ended!" to character
character.quests.get("short-quest").progress()
```
### A useful Command
The player must know which quests they have and be able to inspect them. Here's a simple `quests` command to handle this:
```python
# in evadventure/quests.py
class CmdQuests(Command):
"""
List all quests and their statuses as well as get info about the status of
a specific quest.
Usage:
quests
quest <questname>
"""
key = "quests"
aliases = ["quest"]
def parse(self):
self.quest_name = self.args.strip()
def func(self):
if self.quest_name:
quest = self.caller.quests.get(self.quest_name)
if not quest:
self.msg(f"Quest {self.quest_name} not found.")
return
self.msg(f"Quest {quest.key}: {quest.status}\n{quest.help()}")
return
quests = self.caller.quests.all()
if not quests:
self.msg("No quests.")
return
for quest in quests:
self.msg(f"Quest {quest.key}: {quest.status}")
```
Add this to the `CharacterCmdSet` in `mygame/commands/default_cmdsets.py`. Follow the [Adding a command lesson](../Part1/Beginner-Tutorial-Adding-Commands.md#add-the-echo-command-to-the-default-cmdset) if you are unsure how to do this. Reload and if you are playing as an `EvAdventureCharacter` you should be able to use `quests` to view your quests.
## Testing
> Create a new folder `evadventure/tests/test_quests.py`.
```{sidebar}
An example test suite for quests is found in `evennia/contrib/tutorials/evadventure`, as [tests/test_quests.py](evennia.contrib.tutorials.evadventure.tests.test_quests).
```
Testing of the quests means creating a test character, making a dummy quest, add it to the character's quest handler and making sure all methods work correcly. Create the testing quest so that it will automatically step forward when calling `.progress()`, so you can make sure it works as intended.
## Conclusions
What we created here is just the framework for questing. The actual complexity will come when creating the quests themselves (that is, implementing the `step_<current_step>(*args, **kwargs)` methods), which is something we'll get to later, in [Part 4](../Part4/Beginner-Tutorial-Part4-Overview.md) of this tutorial.

View file

@ -780,13 +780,14 @@ class CmdSetHelp(CmdHelp):
Edit the help database.
Usage:
sethelp[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text>]
sethelp[/switches] <topic>[[;alias;alias][,category[,locks]]
[= <text or new category>]
Switches:
edit - open a line editor to edit the topic's help text.
replace - overwrite existing help topic.
append - add text to the end of existing topic with a newline between.
extend - as append, but don't add a newline.
category - change category of existing help topic.
delete - remove help topic.
Examples:
@ -794,6 +795,7 @@ class CmdSetHelp(CmdHelp):
sethelp/append pickpocketing,Thievery = This steals ...
sethelp/replace pickpocketing, ,attr(is_thief) = This steals ...
sethelp/edit thievery
sethelp/category thievery = classes
If not assigning a category, the `settings.DEFAULT_HELP_CATEGORY` category
will be used. If no lockstring is specified, everyone will be able to read
@ -840,7 +842,7 @@ class CmdSetHelp(CmdHelp):
key = "sethelp"
aliases = []
switch_options = ("edit", "replace", "append", "extend", "delete")
switch_options = ("edit", "replace", "append", "extend", "category", "delete")
locks = "cmd:perm(Helper)"
help_category = "Building"
arg_regex = None
@ -857,7 +859,7 @@ class CmdSetHelp(CmdHelp):
if not self.args:
self.msg(
"Usage: sethelp[/switches] <topic>[;alias;alias][,category[,locks,..] = <text>"
"Usage: sethelp[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text or new category>]"
)
return
@ -953,7 +955,7 @@ class CmdSetHelp(CmdHelp):
else:
helpentry = create.create_help_entry(
topicstr,
self.rhs,
self.rhs if self.rhs is not None else "",
category=category,
locks=lockstring,
aliases=aliases,
@ -986,6 +988,19 @@ class CmdSetHelp(CmdHelp):
self.msg(f"Entry updated:\n{old_entry.entrytext}{aliastxt}")
return
if "category" in switches:
# set the category
if not old_entry:
self.msg(f"Could not find topic '{topicstr}'{aliastxt}.")
return
if not self.rhs:
self.msg("You must supply a category.")
return
category = self.rhs.lower()
old_entry.help_category = category
self.msg(f"Category for entry '{topicstr}'{aliastxt} changed to '{category}'.")
return
if "delete" in switches or "del" in switches:
# delete the help entry
if not old_entry:

View file

@ -197,6 +197,12 @@ class TestHelp(BaseEvenniaCommandTest):
cmdset=CharacterCmdSet(),
)
self.call(help_module.CmdHelp(), "testhelp", "Help for testhelp", cmdset=CharacterCmdSet())
self.call(
help_module.CmdSetHelp(),
"/category testhelp = misc",
"Category for entry 'testhelp' changed to 'misc'.",
cmdset=CharacterCmdSet(),
)
@parameterized.expand(
[

View file

@ -237,7 +237,7 @@ class CraftingRecipeBase:
**kwargs: Any optional properties relevant to this send.
"""
self.crafter.msg(message, {"type": "crafting"})
self.crafter.msg(text=(message, {"type": "crafting"}))
def pre_craft(self, **kwargs):
"""

View file

@ -78,7 +78,7 @@ class TestCraftingRecipeBase(BaseEvenniaTestCase):
"""Test messaging to crafter"""
self.recipe.msg("message")
self.crafter.msg.assert_called_with("message", {"type": "crafting"})
self.crafter.msg.assert_called_with(text=("message", {"type": "crafting"}))
def test_pre_craft(self):
"""Test validating hook"""
@ -206,7 +206,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
self.assertEqual(result[0].key, "Result1")
self.assertEqual(result[0].tags.all(), ["result1", "resultprot"])
self.crafter.msg.assert_called_with(
recipe.success_message.format(outputs="Result1"), {"type": "crafting"}
text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"})
)
# make sure consumables are gone
@ -235,7 +235,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
self.assertEqual(result[0].key, "Result1")
self.assertEqual(result[0].tags.all(), ["result1", "resultprot"])
self.crafter.msg.assert_called_with(
recipe.success_message.format(outputs="Result1"), {"type": "crafting"}
text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"})
)
# make sure consumables are gone
@ -251,8 +251,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_tool_missing_message.format(outputs="Result1", missing="tool2"),
{"type": "crafting"},
text=(
recipe.error_tool_missing_message.format(outputs="Result1", missing="tool2"),
{"type": "crafting"},
)
)
# make sure consumables are still there
@ -269,8 +271,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"),
{"type": "crafting"},
text=(
recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"),
{"type": "crafting"},
)
)
# make sure consumables are still there
@ -293,8 +297,10 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"),
{"type": "crafting"},
text=(
recipe.error_consumable_missing_message.format(outputs="Result1", missing="cons3"),
{"type": "crafting"},
)
)
# make sure consumables are deleted even though we failed
@ -317,10 +323,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_tool_excess_message.format(
outputs="Result1", excess=wrong.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
text=(
recipe.error_tool_excess_message.format(
outputs="Result1", excess=wrong.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
)
)
# make sure consumables are still there
self.assertIsNotNone(self.cons1.pk)
@ -342,10 +350,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_tool_excess_message.format(
outputs="Result1", excess=tool3.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
text=(
recipe.error_tool_excess_message.format(
outputs="Result1", excess=tool3.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
)
)
# make sure consumables are still there
@ -369,10 +379,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_consumable_excess_message.format(
outputs="Result1", excess=cons4.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
text=(
recipe.error_consumable_excess_message.format(
outputs="Result1", excess=cons4.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
)
)
# make sure consumables are still there
@ -396,7 +408,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertTrue(result)
self.crafter.msg.assert_called_with(
recipe.success_message.format(outputs="Result1"), {"type": "crafting"}
text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"})
)
# make sure consumables are gone
@ -419,7 +431,7 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertTrue(result)
self.crafter.msg.assert_called_with(
recipe.success_message.format(outputs="Result1"), {"type": "crafting"}
text=(recipe.success_message.format(outputs="Result1"), {"type": "crafting"})
)
# make sure consumables are gone
@ -439,10 +451,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_tool_order_message.format(
outputs="Result1", missing=self.tool2.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
text=(
recipe.error_tool_order_message.format(
outputs="Result1", missing=self.tool2.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
)
)
# make sure consumables are still there
@ -462,10 +476,12 @@ class TestCraftingRecipe(BaseEvenniaTestCase):
result = recipe.craft()
self.assertFalse(result)
self.crafter.msg.assert_called_with(
recipe.error_consumable_order_message.format(
outputs="Result1", missing=self.cons3.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
text=(
recipe.error_consumable_order_message.format(
outputs="Result1", missing=self.cons3.get_display_name(looker=self.crafter)
),
{"type": "crafting"},
)
)
# make sure consumables are still there

View file

@ -47,17 +47,8 @@ from collections import deque
from django.conf import settings
from django.db.models import Q
from evennia import (
CmdSet,
DefaultRoom,
EvEditor,
FuncParser,
InterruptCommand,
default_cmds,
gametime,
utils,
)
from evennia import (CmdSet, DefaultRoom, EvEditor, FuncParser,
InterruptCommand, default_cmds, gametime, utils)
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils.utils import list_to_string, repeat

View file

@ -6,6 +6,7 @@ import time
from anything import Anything
from evennia import DefaultObject, create_object, default_cmds
from evennia.commands.default import building
from evennia.commands.default.tests import BaseEvenniaCommandTest
from evennia.utils.test_resources import BaseEvenniaTest
@ -413,10 +414,9 @@ class TestRPSystemCommands(BaseEvenniaCommandTest):
expected_first_call = [
"More than one match for 'Mushroom' (please narrow target):",
f" Mushroom-1 []",
f" Mushroom-2 []",
f" Mushroom-1",
f" Mushroom-2",
]
self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_first_call)) # PASSES
expected_second_call = f"Mushroom(#{mushroom1.id})\nThe first mushroom is brown."
@ -424,3 +424,13 @@ class TestRPSystemCommands(BaseEvenniaCommandTest):
expected_third_call = f"Mushroom(#{mushroom2.id})\nThe second mushroom is red."
self.call(default_cmds.CmdLook(), "Mushroom-2", expected_third_call) # FAILS
expected_fourth_call = "Alias(es) for 'Mushroom' set to 'fungus'."
self.call(building.CmdSetObjAlias(), "Mushroom-1 = fungus", expected_fourth_call) #PASSES
expected_fifth_call = [
"More than one match for 'Mushroom' (please narrow target):",
f" Mushroom-1 [fungus]",
f" Mushroom-2",
]
self.call(default_cmds.CmdLook(), "Mushroom", "\n".join(expected_fifth_call)) # PASSES

View file

@ -84,15 +84,3 @@ class ObjType(Enum):
MAGIC = "magic"
QUEST = "quest"
TREASURE = "treasure"
class QuestStatus(Enum):
"""
Quest status
"""
STARTED = "started"
COMPLETED = "completed"
ABANDONED = "abandoned"
FAILED = "failed"

View file

@ -2,19 +2,17 @@
A simple quest system for EvAdventure.
A quest is represented by a quest-handler sitting as
.quest on a Character. Individual Quests are objects
that track the state and can have multiple steps, each
of which are checked off during the quest's progress.
The player can use the quest handler to track the
progress of their quests.
`.quests` on a Character. Individual Quests are child classes of `EvAdventureQuest` with
methods for each step of the quest. The quest handler can add, remove, and track the progress
by calling the `progress` method on the quest. Persistent changes are stored on the quester
using the `add_data` and `get_data` methods with an Attribute as storage backend.
A quest ending can mean a reward or the start of
another quest.
"""
from .enums import QuestStatus
from evennia import Command
class EvAdventureQuest:
@ -47,6 +45,9 @@ class EvAdventureQuest:
self.current_step = "end"
self.progress()
def step_B(self, *args, **kwargs):
def step_end(self, *args, **kwargs):
if len(self.quester.contents) > 4:
self.quester.msg("Quest complete!")
@ -54,62 +55,23 @@ class EvAdventureQuest:
```
"""
key = "basequest"
key = "base quest"
desc = "This is the base quest class"
start_step = "start"
completed_text = "This quest is completed!"
abandoned_text = "This quest is abandoned."
# help entries for quests (could also be methods)
help_start = "You need to start first"
help_end = "You need to end the quest"
def __init__(self, quester, data=None):
if " " in self.key:
raise TypeError("The Quest name must not have spaces in it.")
def __init__(self, quester):
self.quester = quester
self.data = data or dict()
self.data = self.questhandler.load_quest_data(self.key)
self._current_step = self.get_data("current_step")
if not self.current_step:
self.current_step = self.start_step
@property
def questhandler(self):
return self.quester.quests
@property
def current_step(self):
return self._current_step
@current_step.setter
def current_step(self, step_name):
self._current_step = step_name
self.add_data("current_step", step_name)
self.questhandler.do_save = True
@property
def status(self):
return self.get_data("status", QuestStatus.STARTED)
@status.setter
def status(self, value):
self.add_data("status", value)
@property
def is_completed(self):
return self.status == QuestStatus.COMPLETED
@property
def is_abandoned(self):
return self.status == QuestStatus.ABANDONED
@property
def is_failed(self):
return self.status == QuestStatus.FAILED
def add_data(self, key, value):
"""
Add data to the quest. This saves it permanently.
@ -120,18 +82,7 @@ class EvAdventureQuest:
"""
self.data[key] = value
self.questhandler.save_quest_data(self.key, self.data)
def remove_data(self, key):
"""
Remove data from the quest permanently.
Args:
key (str): The key to remove.
"""
self.data.pop(key, None)
self.questhandler.save_quest_data(self.key, self.data)
self.questhandler.save_quest_data(self.key)
def get_data(self, key, default=None):
"""
@ -147,23 +98,70 @@ class EvAdventureQuest:
"""
return self.data.get(key, default)
def abandon(self):
def remove_data(self, key):
"""
Call when quest is abandoned.
Remove data from the quest permanently.
Args:
key (str): The key to remove.
"""
self.add_data("status", QuestStatus.ABANDONED)
self.questhandler.clean_quest_data(self.key)
self.cleanup()
self.data.pop(key, None)
self.questhandler.save_quest_data(self.key)
@property
def questhandler(self):
return self.quester.quests
@property
def current_step(self):
return self._current_step
@current_step.setter
def current_step(self, step_name):
self._current_step = step_name
self.add_data("current_step", step_name)
@property
def status(self):
return self.get_data("status", "started")
@status.setter
def status(self, value):
self.add_data("status", value)
@property
def is_completed(self):
return self.status == "completed"
@property
def is_abandoned(self):
return self.status == "abandoned"
@property
def is_failed(self):
return self.status == "failed"
def complete(self):
"""
Call this to end the quest.
Complete the quest.
"""
self.add_data("status", QuestStatus.COMPLETED)
self.questhandler.clean_quest_data(self.key)
self.cleanup()
self.status = "completed"
def abandon(self):
"""
Abandon the quest.
"""
self.status = "abandoned"
def fail(self):
"""
Fail the quest.
"""
self.status = "failed"
def progress(self, *args, **kwargs):
"""
@ -174,34 +172,38 @@ class EvAdventureQuest:
Args:
*args, **kwargs: Will be passed into the step method.
"""
return getattr(self, f"step_{self.current_step}")(*args, **kwargs)
Notes:
`self.quester` is available as the character following the quest.
def help(self):
"""
getattr(self, f"step_{self.current_step}")(*args, **kwargs)
def help(self, *args, **kwargs):
"""
This is used to get help (or a reminder) of what needs to be done to complete the current
quest-step.
quest-step. It will look for a `help_<stepname>` method or string attribute on the quest.
Args:
*args, **kwargs: Will be passed into any help_* method.
Returns:
str: The help text for the current step.
"""
if self.is_completed:
return self.completed_text
if self.is_abandoned:
return self.abandoned_text
if self.status in ("abandoned", "completed", "failed"):
help_resource = getattr(self, f"help_{self.status}",
f"You have {self.status} this quest.")
else:
help_resource = getattr(self, f"help_{self.current_step}", "No help available.")
help_resource = (
getattr(self, f"help_{self.current_step}", None)
or "You need to {self.current_step} ..."
)
if callable(help_resource):
# the help_<current_step> can be a method to call
return help_resource()
# the help_* methods can be used to dynamically generate help
return help_resource(*args, **kwargs)
else:
# normally it's just a string
return str(help_resource)
# step methods and hooks
def step_start(self, *args, **kwargs):
@ -243,7 +245,6 @@ class EvAdventureQuestHandler:
def __init__(self, obj):
self.obj = obj
self.do_save = False
self.quests = {}
self.quest_classes = {}
self._load()
@ -256,7 +257,7 @@ class EvAdventureQuestHandler:
)
# instantiate all quests
for quest_key, quest_class in self.quest_classes.items():
self.quests[quest_key] = quest_class(self.obj, self.load_quest_data(quest_key))
self.quests[quest_key] = quest_class(self.obj)
def _save(self):
self.obj.attributes.add(
@ -264,57 +265,6 @@ class EvAdventureQuestHandler:
self.quest_classes,
category=self.quest_storage_attribute_category,
)
self._load() # important
self.do_save = False
def save_quest_data(self, quest_key, data):
"""
Save data for a quest. We store this on the quester as well as updating the quest itself.
Args:
data (dict): The data to store. This is commonly flags or other data needed to track the
quest.
"""
quest = self.get(quest_key)
if quest:
quest.data = data
self.obj.attributes.add(
self.quest_data_attribute_template.format(quest_key=quest_key),
data,
category=self.quest_data_attribute_category,
)
def load_quest_data(self, quest_key):
"""
Load data for a quest.
Args:
quest_key (str): The quest to load data for.
Returns:
dict: The data stored for the quest.
"""
return self.obj.attributes.get(
self.quest_data_attribute_template.format(quest_key=quest_key),
category=self.quest_data_attribute_category,
default={},
)
def clean_quest_data(self, quest_key):
"""
Remove data for a quest.
Args:
quest_key (str): The quest to remove data for.
"""
self.obj.attributes.remove(
self.quest_data_attribute_template.format(quest_key=quest_key),
category=self.quest_data_attribute_category,
)
def has(self, quest_key):
"""
@ -344,6 +294,16 @@ class EvAdventureQuestHandler:
"""
return self.quests.get(quest_key)
def all(self):
"""
Get all quests stored on character.
Returns:
list: All quests stored on character.
"""
return list(self.quests.values())
def add(self, quest_class):
"""
Add a new quest
@ -353,6 +313,7 @@ class EvAdventureQuestHandler:
"""
self.quest_classes[quest_class.key] = quest_class
self.quests[quest_class.key] = quest_class(self.obj)
self._save()
def remove(self, quest_key):
@ -368,50 +329,74 @@ class EvAdventureQuestHandler:
# make sure to cleanup
quest.abandon()
self.quest_classes.pop(quest_key, None)
self.quests.pop(quest_key, None)
self._save()
def get_help(self, quest_key=None):
def save_quest_data(self, quest_key):
"""
Get help text for a quest or for all quests. The help text is
a combination of the description of the quest and the help-text
of the current step.
Save data for a quest. We store this on the quester as well as updating the quest itself.
Args:
quest_key (str, optional): The quest-key. If not given, get help for all
quests in handler.
quest_key (str): The quest to save data for. The data is assumed to be stored on the
quest as `.data` (a dict).
"""
quest = self.get(quest_key)
if quest:
self.obj.attributes.add(
self.quest_data_attribute_template.format(quest_key=quest_key),
quest.data,
category=self.quest_data_attribute_category,
)
def load_quest_data(self, quest_key):
"""
Load data for a quest.
Args:
quest_key (str): The quest to load data for.
Returns:
list: Help texts, one for each quest, or only one if `quest_key` is given.
dict: The data stored for the quest.
"""
help_texts = []
if quest_key in self.quests:
quests = [self.quests[quest_key]]
else:
quests = self.quests.values()
return self.obj.attributes.get(
self.quest_data_attribute_template.format(quest_key=quest_key),
category=self.quest_data_attribute_category,
default={},
)
class CmdQuests(Command):
"""
List all quests and their statuses as well as get info about the status of
a specific quest.
Usage:
quests
quest <questname>
"""
key = "quests"
aliases = ["quest"]
def parse(self):
self.quest_name = self.args.strip()
def func(self):
if self.quest_name:
quest = self.caller.quests.get(self.quest_name)
if not quest:
self.msg(f"Quest {self.quest_name} not found.")
return
self.msg(f"Quest {quest.key}: {quest.status}\n{quest.help()}")
return
quests = self.caller.quests.all()
if not quests:
self.msg("No quests.")
return
for quest in quests:
help_texts.append(f"|c{quest.key}|n\n {quest.desc}\n\n - {quest.help()}")
return help_texts
self.msg(f"Quest {quest.key}: {quest.status}")
def progress(self, quest_key=None, *args, **kwargs):
"""
Check progress of a given quest or all quests.
Args:
quest_key (str, optional): If given, check the progress of this quest (if we have it),
otherwise check progress on all quests.
*args, **kwargs: Will be passed into each quest's `progress` call.
"""
if quest_key in self.quests:
quests = [self.quests[quest_key]]
else:
quests = self.quests.values()
for quest in quests:
quest.progress(*args, **kwargs)
if self.do_save:
# do_save is set by the quest
self._save()

View file

@ -99,52 +99,50 @@ class EvAdventureQuestTest(EvAdventureMixin, BaseEvenniaTest):
def test_help(self):
"""Get help"""
# get help for all quests
help_txt = self.character.quests.get_help()
self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."])
# get help for one specific quest
help_txt = self.character.quests.get_help(_TestQuest.key)
self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - You need to do A first."])
quest = self._get_quest()
# get help for a specific quest
help_txt = quest.help()
self.assertEqual(help_txt, "You need to do A first.")
# help for finished quest
self._get_quest().complete()
help_txt = self.character.quests.get_help()
self.assertEqual(help_txt, ["|ctestquest|n\n A test quest!\n\n - This quest is completed!"])
quest.complete()
help_txt = quest.help()
self.assertEqual(help_txt, "You have completed this quest.")
def test_progress__fail(self):
"""
Check progress without having any.
"""
# progress all quests
self.character.quests.progress()
# progress one quest
self.character.quests.progress(_TestQuest.key)
quest = self._get_quest()
# progress quest
quest.progress()
# still on step A
self.assertEqual(self._get_quest().current_step, "A")
self.assertEqual(quest.current_step, "A")
def test_progress(self):
"""
Fulfill the quest steps in sequess
Fulfill the quest steps in sequence.
"""
quest = self._get_quest()
# A requires a certain object in inventory
self._fulfillA()
self.character.quests.progress()
self.assertEqual(self._get_quest().current_step, "B")
quest.progress()
self.assertEqual(quest.current_step, "B")
# B requires progress be called with specific kwarg
# should not step (no kwarg)
self.character.quests.progress()
self.assertEqual(self._get_quest().current_step, "B")
quest.progress()
self.assertEqual(quest.current_step, "B")
# should step (kwarg sent)
self.character.quests.progress(complete_quest_B=True)
self.assertEqual(self._get_quest().current_step, "C")
quest.progress(complete_quest_B=True)
self.assertEqual(quest.current_step, "C")
# C requires a counter Attribute on char be high enough
self._fulfillC()
self.character.quests.progress()
self.assertEqual(self._get_quest().current_step, "C") # still on last step
self.assertEqual(self._get_quest().is_completed, True)
quest.progress()
self.assertEqual(quest.current_step, "C") # still on last step
self.assertEqual(quest.is_completed, True)

View file

@ -227,7 +227,7 @@ class FileHelpStorageHandler:
for dct in loaded_help_dicts:
key = dct.get("key").lower().strip()
category = dct.get("category", _DEFAULT_HELP_CATEGORY).strip()
category = dct.get("category", _DEFAULT_HELP_CATEGORY).lower().strip()
aliases = list(dct.get("aliases", []))
entrytext = dct.get("text", "")
locks = dct.get("locks", "")

View file

@ -138,5 +138,5 @@ class TestFileHelp(TestCase):
for inum, helpentry in enumerate(result):
self.assertEqual(HELP_ENTRY_DICTS[inum]["key"], helpentry.key)
self.assertEqual(HELP_ENTRY_DICTS[inum].get("aliases", []), helpentry.aliases)
self.assertEqual(HELP_ENTRY_DICTS[inum]["category"], helpentry.help_category)
self.assertEqual(HELP_ENTRY_DICTS[inum]["category"].lower(), helpentry.help_category)
self.assertEqual(HELP_ENTRY_DICTS[inum]["text"], helpentry.entrytext)

View file

@ -2070,7 +2070,7 @@ def format_grid(elements, width=78, sep=" ", verbatim_elements=None, line_prefi
else:
row += " " * max(0, width - lrow)
rows.append(row)
row = ""
row = element
ic = 0
else:
# add a new slot
@ -2403,9 +2403,9 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
aliases = result.aliases.all(return_objs=True)
# remove pluralization aliases
aliases = [
alias
alias.db_key
for alias in aliases
if hasattr(alias, "category") and alias.category not in ("plural_key",)
if alias.db_category != "plural_key"
]
else:
# result is likely a Command, where `.aliases` is a list of strings.
@ -2416,7 +2416,7 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
name=result.get_display_name(caller)
if hasattr(result, "get_display_name")
else query,
aliases=" [{alias}]".format(alias=";".join(aliases) if aliases else ""),
aliases=" [{alias}]".format(alias=";".join(aliases)) if aliases else "",
info=result.get_extra_info(caller),
)
matches = None