From 2ec97f102d21fe4552055882fab56ca55e2345ed Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Fri, 29 Mar 2024 20:40:46 +1100 Subject: [PATCH 01/14] Fix aliases not being shown for disambiguation in utils.at_search_result() Also correct misplaced 'if' used to avoid '[]' in the case of no aliases and update unit tests. --- evennia/contrib/rpg/rpsystem/tests.py | 16 +++++++++++++--- evennia/utils/utils.py | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index 155af26672..04d4ef49de 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -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 diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index a3e3633a94..6748d606f7 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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 From 334bfaefadd8c96dcf42965791c8d1fddfafbebb Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Fri, 29 Mar 2024 12:49:19 -0600 Subject: [PATCH 02/14] fix crafting msg traceback --- evennia/contrib/game_systems/crafting/crafting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/game_systems/crafting/crafting.py b/evennia/contrib/game_systems/crafting/crafting.py index 593f1a04b9..875e843d5f 100644 --- a/evennia/contrib/game_systems/crafting/crafting.py +++ b/evennia/contrib/game_systems/crafting/crafting.py @@ -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): """ From 8f67f3934c114d051fb963e631ea20a9bc55ef30 Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Sat, 30 Mar 2024 12:48:38 +1100 Subject: [PATCH 03/14] Fix traceback when using sethelp/edit to create a new topic --- evennia/commands/default/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 9956a23c08..75860e976c 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -953,7 +953,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, From 03e6f6811cf5e31649ce3fc63d2fac420f00a8e8 Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Sat, 30 Mar 2024 13:29:40 +1100 Subject: [PATCH 04/14] Fix weighted_rows() sometimes 'losing' items --- evennia/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index a3e3633a94..ce21cc1d9d 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -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 From 8357232dc0877b544f40be094cd08bf4df6069f7 Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Sat, 30 Mar 2024 17:34:55 +1100 Subject: [PATCH 05/14] Fix file help topic category casing differences appearing as duplicated categories --- evennia/help/filehelp.py | 2 +- evennia/help/tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/help/filehelp.py b/evennia/help/filehelp.py index 3f8a598249..4c9cf3d9f1 100644 --- a/evennia/help/filehelp.py +++ b/evennia/help/filehelp.py @@ -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", "") diff --git a/evennia/help/tests.py b/evennia/help/tests.py index 6989fddfb3..0f7f31f242 100644 --- a/evennia/help/tests.py +++ b/evennia/help/tests.py @@ -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) From f1220e7b0219782e3cb30490ff7694ffecc06232 Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Sat, 30 Mar 2024 18:30:41 +1100 Subject: [PATCH 06/14] Add sethelp/category switch for changing database help topic's category --- evennia/commands/default/help.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 9956a23c08..90a67602f2 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -780,13 +780,14 @@ class CmdSetHelp(CmdHelp): Edit the help database. Usage: - sethelp[/switches] [[;alias;alias][,category[,locks]] [= ] - + sethelp[/switches] [[;alias;alias][,category[,locks]] + [= ] 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] [;alias;alias][,category[,locks,..] = " + "Usage: sethelp[/switches] [[;alias;alias][,category[,locks]] [= ]" ) return @@ -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: From f8b9ef231140ca1e78cef85efb681b9a681698d1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Mar 2024 15:56:36 +0100 Subject: [PATCH 07/14] Update CHANGELOG --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b1034a9e..caf9268ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,23 @@ adding line breaks in hook returns. (InspectorCaracal) - [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 ` 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 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 ## Evennia 4.0.0 From 9f9d58bedc5dc150a0957491970d5aa6770caa96 Mon Sep 17 00:00:00 2001 From: Chiizujin Date: Sun, 31 Mar 2024 02:27:30 +1100 Subject: [PATCH 08/14] Add unit test for sethelp/category --- evennia/commands/default/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8977c890d3..3a272e04f7 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -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( [ From 5fcccca7d1c93bc7ac0a64b3d4ee46fad1367960 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Mar 2024 16:43:10 +0100 Subject: [PATCH 09/14] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf9268ce7..f08c63febf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - [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 @@ -24,6 +26,7 @@ [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 From 540729e2b43683a00b3e0e881b1f57f0d3e023a6 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sat, 30 Mar 2024 13:38:13 -0600 Subject: [PATCH 10/14] update crafting contrib tests --- .../contrib/game_systems/crafting/tests.py | 76 +++++++++++-------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/evennia/contrib/game_systems/crafting/tests.py b/evennia/contrib/game_systems/crafting/tests.py index 41ae4412bf..9e42695d1d 100644 --- a/evennia/contrib/game_systems/crafting/tests.py +++ b/evennia/contrib/game_systems/crafting/tests.py @@ -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 @@ -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 From a0754d9f1a1ba0131c809e3872e3c2e1ad87c117 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sat, 30 Mar 2024 14:14:30 -0600 Subject: [PATCH 11/14] add missing paren --- evennia/contrib/game_systems/crafting/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/game_systems/crafting/tests.py b/evennia/contrib/game_systems/crafting/tests.py index 9e42695d1d..7dff337035 100644 --- a/evennia/contrib/game_systems/crafting/tests.py +++ b/evennia/contrib/game_systems/crafting/tests.py @@ -78,7 +78,7 @@ class TestCraftingRecipeBase(BaseEvenniaTestCase): """Test messaging to crafter""" self.recipe.msg("message") - self.crafter.msg.assert_called_with(text="message", {"type": "crafting"})) + self.crafter.msg.assert_called_with(text=("message", {"type": "crafting"})) def test_pre_craft(self): """Test validating hook""" From 8f14cef6e60efdbf51426531a27cdb20b0eb96f9 Mon Sep 17 00:00:00 2001 From: InspectorCaracal <51038201+InspectorCaracal@users.noreply.github.com> Date: Sat, 30 Mar 2024 14:36:01 -0600 Subject: [PATCH 12/14] missed another msg test --- evennia/contrib/game_systems/crafting/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/game_systems/crafting/tests.py b/evennia/contrib/game_systems/crafting/tests.py index 7dff337035..201fa487b6 100644 --- a/evennia/contrib/game_systems/crafting/tests.py +++ b/evennia/contrib/game_systems/crafting/tests.py @@ -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 From f1f1f6219a806e9a31d65cea7ef86b0a334f6fe2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Mar 2024 23:42:16 +0100 Subject: [PATCH 13/14] Implemented EvAdventure quest code+tutorial lesson --- CHANGELOG.md | 2 +- docs/source/Coding/Changelog.md | 16 + .../Part3/Beginner-Tutorial-AI.md | 2 + .../Part3/Beginner-Tutorial-Combat-Base.md | 4 +- .../Beginner-Tutorial-Combat-Turnbased.md | 2 +- .../Part3/Beginner-Tutorial-Quests.md | 399 +++++++++++++++++- .../grid/extended_room/extended_room.py | 13 +- .../contrib/tutorials/evadventure/enums.py | 12 - .../contrib/tutorials/evadventure/quests.py | 329 +++++++-------- .../evadventure/tests/test_quests.py | 48 +-- 10 files changed, 600 insertions(+), 227 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f08c63febf..a671619315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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 and Procedural dungeon (Griatch) +- Doc: Added Beginner Tutorial lessons for AI, Quests and Procedural dungeon (Griatch) - Doc fixes (Griatch, InspectorCaracal) [pull3421]: https://github.com/evennia/evennia/pull/3421 diff --git a/docs/source/Coding/Changelog.md b/docs/source/Coding/Changelog.md index 3740618239..a671619315 100644 --- a/docs/source/Coding/Changelog.md +++ b/docs/source/Coding/Changelog.md @@ -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 ` 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 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 115c61faf4..4e35e2b2c4 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-AI.md @@ -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 diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md index 12d15ff8c9..b5611979af 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Combat-Base.md @@ -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. 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 c456cd51d2..9932a5a5a1 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 @@ -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: diff --git a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md index 2398ff20fe..e4aee29fe6 100644 --- a/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md +++ b/docs/source/Howtos/Beginner-Tutorial/Part3/Beginner-Tutorial-Quests.md @@ -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. \ No newline at end of file +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_(*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_`. 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 + + """ + 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_(*args, **kwargs)` methods), which is something we'll get to later, in [Part 4](../Part4/Beginner-Tutorial-Part4-Overview.md) of this tutorial. \ No newline at end of file diff --git a/evennia/contrib/grid/extended_room/extended_room.py b/evennia/contrib/grid/extended_room/extended_room.py index b23338b709..97dec6056e 100644 --- a/evennia/contrib/grid/extended_room/extended_room.py +++ b/evennia/contrib/grid/extended_room/extended_room.py @@ -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 diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py index 4ec4405e9f..6ba77d82f9 100644 --- a/evennia/contrib/tutorials/evadventure/enums.py +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -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" diff --git a/evennia/contrib/tutorials/evadventure/quests.py b/evennia/contrib/tutorials/evadventure/quests.py index 1e1f2780da..38476a058e 100644 --- a/evennia/contrib/tutorials/evadventure/quests.py +++ b/evennia/contrib/tutorials/evadventure/quests.py @@ -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_` 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_ 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 + + """ + 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() diff --git a/evennia/contrib/tutorials/evadventure/tests/test_quests.py b/evennia/contrib/tutorials/evadventure/tests/test_quests.py index 2a6fe61021..675cf3d498 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_quests.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_quests.py @@ -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) From a3066c9b59a038958bf17f0a0c9d4a6ece3e72b8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Mar 2024 23:46:47 +0100 Subject: [PATCH 14/14] Update Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a671619315..3349e8a9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ 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) @@ -27,6 +29,7 @@ [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