From e890bd9040e6208df7cb779fc634f2c72d088b07 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Nov 2020 17:11:49 +0100 Subject: [PATCH] Expand with many crafting unit tests --- evennia/contrib/crafting/crafting.py | 502 ++++++++++----- evennia/contrib/crafting/example_recipes.py | 19 +- evennia/contrib/crafting/tests.py | 672 ++++++++++++++++++++ evennia/objects/objects.py | 4 +- evennia/prototypes/spawner.py | 11 +- 5 files changed, 1032 insertions(+), 176 deletions(-) create mode 100644 evennia/contrib/crafting/tests.py diff --git a/evennia/contrib/crafting/crafting.py b/evennia/contrib/crafting/crafting.py index 61163e6435..cd4c7cdbbe 100644 --- a/evennia/contrib/crafting/crafting.py +++ b/evennia/contrib/crafting/crafting.py @@ -63,7 +63,8 @@ Recipes are put in one or more modules added as a list to the CRAFT_MODULE_RECIPES = ['world.recipes_weapons', 'world.recipes_potions'] Below is an example of a crafting recipe. See the `CraftingRecipe` class for -details of which properties and methods are available. +details of which properties and methods are available to override - the craft +behavior can be modified substantially this way. ```python @@ -93,6 +94,8 @@ recipes. from copy import copy from evennia.utils.utils import ( iter_to_str, callables_from_module, inherits_from, make_iter) +from evennia.commands.cmdset import CmdSet +from evennia.commands.command import Command from evennia.prototypes.spawner import spawn from evennia.utils.create import create_object @@ -131,8 +134,11 @@ class CraftingValidationError(CraftingError): class CraftingRecipeBase: """ - This is the base of the crafting system. The recipe handles all aspects of - performing a 'craft' operation. + The recipe handles all aspects of performing a 'craft' operation. This is + the base of the crafting system, intended to be replace if you want to + adapt it for very different functionality - see the `CraftingRecipe` child + class for an implementation of the most common type of crafting using + objects. Example of usage: :: @@ -144,6 +150,19 @@ class CraftingRecipeBase: consumed - so in that case the recipe cannot be used a second time (doing so will raise a `CraftingError`) + Process: + + 1. `.craft(**kwargs)` - this starts the process on the initialized recipe. The kwargs + are optional but will be passed into all of the following hooks. + 2. `.pre_craft(**kwargs)` - this normally validates inputs and stores them in + `.validated_inputs.`. Raises `CraftingValidationError` otherwise. + 4. `.do_craft(**kwargs)` - should return the crafted item(s) or the empty list. Any + crafting errors should be immediately reported to user. + 5. `.post_craft(crafted_result, **kwargs)`- always called, even if `pre_craft` + raised a `CraftingError` or `CraftingValidationError`. + Should return `crafted_result` (modified or not). + + """ name = "recipe base" @@ -151,8 +170,6 @@ class CraftingRecipeBase: # don't set this unless crafting inputs are *not* consumed by the crafting # process (otherwise subsequent calls will fail). allow_reuse = False - # this is set to avoid re-validation if recipe is re-run - is_validated = False def __init__(self, crafter, *inputs, **kwargs): """ @@ -183,45 +200,27 @@ class CraftingRecipeBase: """ self.crafter.msg(message, {"type": "crafting"}) - def validate_inputs(self, **kwargs): + def pre_craft(self, **kwargs): """ Hook to override. - Make sure the provided inputs are valid. This should always be run. - This should validate `self.inputs` which are the inputs given when - creating this recipe. + This is called just before crafting operation and is normally + responsible for validating the inputs, storing data on + `self.validated_inputs`. Args: - **kwargs: These are optional extra flags passed during intialization. + **kwargs: Optional extra flags passed during initialization or + `.craft(**kwargs)`. Raises: CraftingValidationError: If validation fails. - Note: - This method should store validated results on the recipe for the - other hooks to access. It is also responsible for properly sending - error messages to e.g. self.crafter (usually via `self.msg`). - """ if self.allow_craft: self.validated_inputs = self.inputs[:] else: raise CraftingValidationError - def pre_craft(self, **kwargs): - """ - Hook to override. - - This is called just before crafting operation, after inputs have been - validated. At this point the validated inputs are available on the - class instance. - - Args: - **kwargs: Optional extra flags passed during initialization. - - """ - pass - def do_craft(self, **kwargs): """ Hook to override. @@ -231,7 +230,6 @@ class CraftingRecipeBase: inputs are available on this recipe instance. Args: - validated_inputs (any): Data previously returned from `pre_craft`. **kwargs: Any extra flags passed at initialization. Returns: @@ -281,29 +279,31 @@ class CraftingRecipeBase: """ craft_result = None - err = "" if self.allow_craft: + + # override/extend craft_kwargs from initialization. craft_kwargs = copy(self.craft_kwargs) craft_kwargs.update(kwargs) try: - # this assigns to self.validated_inputs - if not self.is_validated: - self.validate_inputs(**craft_kwargs) - self.is_validated = True - - # run the crafting process - each accesses validated data directly - self.pre_craft(**craft_kwargs) - craft_result = self.do_craft(**craft_kwargs) - self.post_craft(craft_result, **craft_kwargs) - except (CraftingError, CraftingValidationError) as exc: - # use this to abort crafting early + try: + # this assigns to self.validated_inputs + self.pre_craft(**craft_kwargs) + except (CraftingError, CraftingValidationError): + if raise_exception: + raise + else: + craft_result = self.do_craft(**craft_kwargs) + finally: + craft_result = self.post_craft(craft_result, **craft_kwargs) + except (CraftingError, CraftingValidationError): if raise_exception: raise + # possibly turn off re-use depending on class setting self.allow_craft = self.allow_reuse elif not self.allow_reuse: - raise CraftingError("Cannot re-run crafting without refreshing recipe first.") + raise CraftingError("Cannot re-run crafting without re-initializing recipe first.") if craft_result is None and raise_exception: raise CraftingError(f"Crafting of {self.name} failed.") return craft_result @@ -312,44 +312,52 @@ class CraftingRecipeBase: class CraftingRecipe(CraftingRecipeBase): """ The CraftRecipe implements the most common form of crafting: Combining (and - optionally consuming) inputs to produce a new result. This type of recipe - only works with typeclassed entities as inputs and outputs, since it's - based on Tags and prototypes. + consuming) inputs to produce a new result. This type of recipe only works + with typeclassed entities as inputs and outputs, since it's based on Tags + and Prototypes. There are two types of crafting ingredients: 'tools' and 'consumables'. The difference between them is that the former is not consumed in the crafting - process. So if you need a hammer and anvil to craft a sword, they are 'tools' - whereas the materials of the sword are 'consumables'. + process. So if you need a hammer and anvil to craft a sword, they are + 'tools' whereas the materials of the sword are 'consumables'. Examples: :: - class SwordRecipe(CraftRecipe): - name = "sword" - input_tags = ["hilt", "pommel", "strips of leather", "sword blade"] + class FlourRecipe(CraftRecipe): + name = "flour" + tool_tags = ['windmill'] + consumable_tags = ["wheat"] output_prototypes = [ - {"key": "sword", - "typeclass": "typeclassess.weapons.bladed.Sword", - "tags": [("sword", "weapon"), ("melee", "weapontype"), - ("edged", "weapontype")] + {"key": "Bag of flour", + "typeclass": "typeclasses.food.Flour", + "desc": "A small bag of flour." + "tags": [("flour", "crafting_material"), } - ] + + class BreadRecipe(CraftRecipe): + name = "bread" + tool_tags = ["roller", "owen"] + consumable_tags = ["flour", "egg", "egg", "salt", "water", "yeast"] + output_prototypes = [ + {"key": "bread", + "desc": "A tasty bread." + } + ## Properties on the class level: - `name` (str): The name of this recipe. This should be globally unique. + + ### tools + - `tool_tag_category` (str): What tag-category tools must use. Default is 'crafting_tool'. - - `consumable_tag_category` (str): What tag-category consumables must use. - Default is 'crafting_material'. - `tool_tags` (list): Object-tags to use for tooling. If more than one instace of a tool is needed, add multiple entries here. - - ### cool-settings - - `tool_names` (list): Human-readable names for tools. These are used for informative - messages/errors. If not given, tags will be used. If given, this list should - match the length of `tool_tags`. + messages/errors. If not given, the tags will be used. If given, this list should + match the length of `tool_tags`.: - `exact_tools` (bool, default True): Must have exactly the right tools, any extra leads to failure. - `exact_tool_order` (bool, default False): Tools must be added in exactly the @@ -357,6 +365,8 @@ class CraftingRecipe(CraftingRecipeBase): ### consumables + - `consumable_tag_category` (str): What tag-category consumables must use. + Default is 'crafting_material'. - `consumable_tags` (list): Tags for objects that will be consumed as part of running the recipe. - `consumable_names` (list): Human-readable names for consumables. Same as for tools. @@ -368,7 +378,8 @@ class CraftingRecipe(CraftingRecipeBase): another order than given will lead to failing crafting. - `consume_on_fail` (bool, default False): Normally, consumables remain if crafting fails. With this flag, a failed crafting will still consume - ingredients. + consumables. Note that this will also consume any 'extra' consumables + added not part of the recipe! ### outputs (result of crafting) @@ -380,9 +391,12 @@ class CraftingRecipe(CraftingRecipeBase): ### custom error messages - custom messages all have custom formatting markers (default strings are shown): + custom messages all have custom formatting markers. Many are empty strings + when not applicable. + :: {missing}: Comma-separated list of tool/consumable missing for missing/out of order errors. + {excess}: Comma-separated list of tool/consumable added in excess of recipe {inputs}: Comma-separated list of any inputs (tools + consumables) involved in error. {tools}: Comma-sepatated list of tools involved in error. {consumables}: Comma-separated list of consumables involved in error. @@ -392,23 +406,29 @@ class CraftingRecipe(CraftingRecipeBase): {o0}..{oN-1}: Individual outputs, same order as `.output_names`. - `error_tool_missing_message`: "Could not craft {outputs} without {missing}." - - `error_tool_order_message`: "Could not craft {outputs} since - {missing} was added in the wrong order." + - `error_tool_order_message`: + "Could not craft {outputs} since {missing} was added in the wrong order." + - `error_tool_excess_message`: "Could not craft {outputs} (extra {excess})." - `error_consumable_missing_message`: "Could not craft {outputs} without {missing}." - - `error_consumable_order_message`: "Could not craft {outputs} since - {missing} was added in the wrong order." + - `error_consumable_order_message`: + "Could not craft {outputs} since {missing} was added in the wrong order." + - `error_consumable_excess_message`: "Could not craft {outputs} (excess {excess})." - `success_message`: "You successfuly craft {outputs}!" - - `failed_message`: "You failed to craft {outputs}." + - `failure_message`: "" (this is handled by the other error messages by default) ## Hooks - 1. Crafting starts by calling `.craft` on the parent class. - 2. `.validate_inputs` is called. This returns all valid `(tools, consumables)` - 3. `.pre_craft` is called with the valid `(tools, consumables)`. - 4. `.do_craft` is called, it should return the final result, if any - 5. `.post_craft` is called with both inputs and final result, if any. It should - return the final result or None. By default, this calls the - success/error messages and deletes consumables. + 1. Crafting starts by calling `.craft(**kwargs)` on the parent class. The + `**kwargs` are optional, extends any `**kwargs` passed to the class + constructor and will be passed into all the following hooks. + 3. `.pre_craft(**kwargs)` should handle validation of inputs. Results should + be stored in `validated_consumables/tools` respectively. Raises `CraftingValidationError` + otherwise. + 4. `.do_craft(**kwargs)` will not be called if validation failed. Should return + a list of the things crafted. + 5. `.post_craft(crafting_result, **kwargs)` is always called, also if validation + failed (`crafting_result` will then be falsy). It does any cleanup. By default + this deletes consumables. Use `.msg` to conveniently send messages to the crafter. Raise `evennia.contrib.crafting.crafting.CraftingError` exception to abort @@ -440,6 +460,9 @@ class CraftingRecipe(CraftingRecipeBase): # tool out of order error_tool_order_message = \ "Could not craft {outputs} since {missing} was added in the wrong order." + # if .exact_tools is set and there are more than needed + error_tool_excess_message = \ + "Could not craft {outputs} without the exact tools (extra {excess})." # a list of tag-keys (of the `tag_category`). If more than one of each type # is needed, there should be multiple same-named entries in this list. @@ -462,6 +485,9 @@ class CraftingRecipe(CraftingRecipeBase): # consumable out of order error_consumable_order_message = \ "Could not craft {outputs} since {missing} was added in the wrong order." + # if .exact_consumables is set and there are more than needed + error_consumable_excess_message = \ + "Could not craft {outputs} without the exact ingredients (extra {excess})." # this is a list of one or more prototypes (prototype_keys to existing # prototypes or full prototype-dicts) to use to build the result. All of @@ -472,25 +498,35 @@ class CraftingRecipe(CraftingRecipeBase): # prototype's key or typeclass will be used. If given, this must have the same length # as `output_prototypes`. output_names = [] - + # general craft-failure msg to show after other error-messages. + failure_message = "" + # show after a successful craft success_message = "You successfully craft {outputs}!" - # custom craft-failure. - failed_message = "Failed to craft {outputs}." - def __init__(self, *args, **kwargs): + def __init__(self, crafter, *inputs, **kwargs): """ - Internally, this class stores validated data in - `.validated_consumables` and `.validated_tools` respectively. The - `.validated_inputs` holds a list of all types in the order inserted - to the class constructor. + Args: + crafter (Object): The one doing the crafting. + *inputs (Object): The ingredients (+tools) of the recipe to use. The + The recipe will itself figure out (from tags) which is a tool and + which is a consumable. + **kwargs (any): Any other parameters that are relevant for + this recipe. These will be passed into the crafting hooks. + + Notes: + Internally, this class stores validated data in + `.validated_consumables` and `.validated_tools` respectively. The + `.validated_inputs` property (from parent) holds a list of everything + types in the order inserted to the class constructor. """ - super().__init__(*args, **kwargs) + super().__init__(crafter, *inputs, **kwargs) self.validated_consumables = [] self.validated_tools = [] + # validate class properties if self.consumable_names: assert len(self.consumable_names) == len(self.consumable_tags), \ f"Crafting {self.__class__}.consumable_names list must " \ @@ -526,11 +562,12 @@ class CraftingRecipe(CraftingRecipeBase): def _format_message(self, message, **kwargs): missing = iter_to_str(kwargs.get("missing", "")) + excess = iter_to_str(kwargs.get("excess", "")) involved_tools = iter_to_str(kwargs.get("tools", "")) involved_cons = iter_to_str(kwargs.get("consumables", "")) # build template context - mapping = {"missing": iter_to_str(missing)} + mapping = {"missing": missing, "excess": excess} mapping.update({ f"i{ind}": self.consumable_names[ind] for ind, name in enumerate(self.consumable_names or self.consumable_tags) @@ -548,18 +585,19 @@ class CraftingRecipe(CraftingRecipeBase): # populate template and return return message.format(**mapping) - def seed(self, tool_kwargs=None, consumable_kwargs=None): + @classmethod + def seed(cls, tool_kwargs=None, consumable_kwargs=None): """ - This is a helper method for easy testing and application of this - recipe. When called, it will create simple dummy ingredients with - names and tags needed by this recipe. + This is a helper class-method for easy testing and application of this + recipe. When called, it will create simple dummy ingredients with names + and tags needed by this recipe. Args: consumable_kwargs (dict, optional): This will be passed as - `**kwargs` into the `create_object` call for each consumable. + `**consumable_kwargs` into the `create_object` call for each consumable. If not given, matching `consumable_name` or `consumable_tag` will be used for key. - tool_kwargs (dict, optional): Will be passed as `**kwargs` into the `create_object` + tool_kwargs (dict, optional): Will be passed as `**tool_kwargs` into the `create_object` call for each tool. If not given, the matching `tool_name` or `tool_tag` will be used for key. @@ -567,6 +605,13 @@ class CraftingRecipe(CraftingRecipeBase): tuple: A tuple `(tools, consumables)` with newly created dummy objects matching the recipe ingredient list. + Example: + :: + + tools, consumables = SwordRecipe.seed() + recipe = SwordRecipe(caller, *(tools + consumables)) + result = recipe.craft() + Notes: If `key` is given in `consumable/tool_kwargs` then _every_ created item of each type will have the same key. @@ -582,76 +627,103 @@ class CraftingRecipe(CraftingRecipeBase): cons_tags = consumable_kwargs.pop("tags", []) tools = [] - for itag, tag in enumerate(self.tool_tags): - tools.extend( + for itag, tag in enumerate(cls.tool_tags): + + tools.append( create_object( - key=tool_key or (self.tool_names[itag] if self.tool_names - else tag.capitalize()), - tags=[(tag, self.tool_tag_category), *tool_tags], + key=tool_key or (cls.tool_names[itag] if cls.tool_names else tag.capitalize()), + tags=[(tag, cls.tool_tag_category), *tool_tags], **tool_kwargs ) ) consumables = [] - for itag, tag in enumerate(self.consumable_tags): - consumables.extend( + for itag, tag in enumerate(cls.consumable_tags): + consumables.append( create_object( - key=cons_key or (self.consumable_names[itag] if - self.consumable_names else + key=cons_key or (cls.consumable_names[itag] if + cls.consumable_names else tag.capitalize()), - tags=[(tag, self.consumable_tag_category), *cons_tags] - + tags=[(tag, cls.consumable_tag_category), *cons_tags], + **consumable_kwargs ) ) return tools, consumables - def validate_inputs(self, **kwargs): + def pre_craft(self, **kwargs): """ - Check so the given inputs are what is needed. This operates on `self.inputs` which - is set to the inputs added to the class constructor. Validated data is stored as - lists on `.validated_tools` and `.validated_consumables` respectively. + Do pre-craft checks, including input validation. + + Check so the given inputs are what is needed. This operates on + `self.inputs` which is set to the inputs added to the class + constructor. Validated data is stored as lists on `.validated_tools` + and `.validated_consumables` respectively. Args: **kwargs: Any optional extra kwargs passed during initialization of the recipe class. + Raises: + CraftingValidationError: If validation fails. At this point the crafter + is expected to have been informed of the problem already. + """ def _check_completeness( tagmap, taglist, namelist, exact_match, exact_order, - error_missing_message, error_order_message): - """Compare tagmap to taglist""" + error_missing_message, error_order_message, error_excess_message): + """Compare tagmap (inputs) to taglist (required)""" valids = [] for itag, tagkey in enumerate(taglist): found_obj = None - for obj, taglist in tagmap.items(): - if tagkey in taglist: + for obj, objtags in tagmap.items(): + if tagkey in objtags: found_obj = obj break - if exact_match: - # if we get here, we have a no-match - self.msg(self._format_message( - error_missing_message, - missing=namelist[itag] if namelist else tagkey.capitalize())) - raise CraftingValidationError if exact_order: # if we get here order is wrong - self.msg(self._format_message( + err = self._format_message( error_order_message, - missing=namelist[itag] if namelist else tagkey.capitalize())) - raise CraftingValidationError + missing=obj.get_display_name(looker=self.crafter)) + self.msg(err) + raise CraftingValidationError(err) # since we pop from the mapping, it gets ever shorter match = tagmap.pop(found_obj, None) if match: valids.append(found_obj) + elif exact_match: + err = self._format_message( + error_missing_message, + missing=namelist[itag] if namelist else tagkey.capitalize()) + self.msg(err) + raise CraftingValidationError(err) + + if exact_match and tagmap: + # something is left in tagmap, that means it was never popped and + # thus this is not an exact match + err = self._format_message( + error_excess_message, + excess=[obj.get_display_name(looker=self.crafter) for obj in tagmap]) + self.msg(err) + raise CraftingValidationError(err) + return valids # get tools and consumables from self.inputs tool_map = {obj: obj.tags.get(category=self.tool_tag_category, return_list=True) - for obj in self.inputs if obj and hasattr(obj, "tags")} - consumable_map = {obj: obj.tags.get(category=self.tag_category, return_list=True) + for obj in self.inputs if obj and hasattr(obj, "tags") and + inherits_from(obj, "evennia.objects.models.ObjectDB")} + tool_map = {obj: tags for obj, tags in tool_map.items() if tags} + consumable_map = {obj: obj.tags.get(category=self.consumable_tag_category, return_list=True) for obj in self.inputs - if obj and hasattr(obj, "tags") and obj not in tool_map} + if obj and hasattr(obj, "tags") and obj not in tool_map and + inherits_from(obj, "evennia.objects.models.ObjectDB")} + consumable_map = {obj: tags for obj, tags in consumable_map.items() if tags} + + # we set these so they are available for error management at all times, + # they will be updated with the actual values at the end + self.validated_tools = [obj for obj in tool_map] + self.validated_consumables = [obj for obj in consumable_map] tools = _check_completeness( tool_map, @@ -660,7 +732,8 @@ class CraftingRecipe(CraftingRecipeBase): self.exact_tools, self.exact_tool_order, self.error_tool_missing_message, - self.error_tool_order_message + self.error_tool_order_message, + self.error_tool_excess_message, ) consumables = _check_completeness( consumable_map, @@ -669,53 +742,38 @@ class CraftingRecipe(CraftingRecipeBase): self.exact_consumables, self.exact_consumable_order, self.error_consumable_missing_message, - self.error_consumable_order_message + self.error_consumable_order_message, + self.error_consumable_excess_message, ) + # regardless of flags, the tools/consumable lists much contain exactly # all the recipe needs now. if len(tools) != len(self.tool_tags): - raise CraftingValidationError - if len(consumables) == len(self.consumable_tags): - raise CraftingValidationError + raise CraftingValidationError( + f"Tools {tools}'s tags do not match expected tags {self.tool_tags}") + if len(consumables) != len(self.consumable_tags): + raise CraftingValidationError( + f"Consumables {consumables}'s tags do not match " + f"expected tags {self.consumable_tags}") - # all is ok! self.validated_tools = tools - self.validated_consumables = tools - - # including also empty hooks here for easier reference - - def pre_craft(self, **kwargs): - """ - Hook to override. - - This is called just before crafting operation, after inputs have - been validated. - - Args: - *validated_inputs (any): Data previously returned from - `validate_inputs`. This is a tuple `(tools, consumables)`. - **kwargs (any): Passed from `self.craft`. - - Returns: - any: The validated_inputs, modified or not. - - """ - pass + self.validated_consumables = consumables def do_craft(self, **kwargs): """ - Hook to override. + Hook to override. This will not be called if validation in `pre_craft` + fails. This performs the actual crafting. At this point the inputs are expected to have been verified already. - Args: - validated_inputs (tuple): A tuple `(tools, consumables)`. - Returns: - list: A list of spawned objects created from the inputs. + list: A list of spawned objects created from the inputs, or None + on a failure. Notes: + This method should use `self.msg` to inform the user about the + specific reason of failure immediately. We may want to analyze the tools in some way here to affect the crafting process. @@ -725,22 +783,24 @@ class CraftingRecipe(CraftingRecipeBase): def post_craft(self, craft_result, **kwargs): """ Hook to override. - This is called just after crafting has finished. A common use of this method is to delete the inputs. Args: - craft_result (any): The crafted result, provided by `self.do_craft`. - validated_inputs (tuple): the validated inputs, a tuple `(tools, consumables)`. + craft_result (list): The crafted result, provided by `self.do_craft`. **kwargs (any): Passed from `self.craft`. Returns: - any: The return of the craft, possibly modified in this method. + list: The return(s) of the craft, possibly modified in this method. + + Notes: + This is _always_ called, also if validation in `pre_craft` fails + (`craft_result` will then be `None`). """ if craft_result: self.msg(self._format_message(self.success_message)) - else: + elif self.failure_message: self.msg(self._format_message(self.failure_message)) if craft_result or self.consume_on_fail: @@ -751,10 +811,10 @@ class CraftingRecipe(CraftingRecipeBase): return craft_result -# access functions +# access function -def craft(crafter, recipe_name, *inputs, return_list=True, raise_exception=False, **kwargs): +def craft(crafter, recipe_name, *inputs, raise_exception=False, **kwargs): """ Craft a given recipe from a source recipe module. A recipe module is a Python module containing recipe classes. Note that this requires @@ -763,10 +823,8 @@ def craft(crafter, recipe_name, *inputs, return_list=True, raise_exception=False Args: crafter (Object): The one doing the crafting. - recipe_name (str): This should match the `CraftRecipe.name` to use. + recipe_name (str): The `CraftRecipe.name` to use. *inputs: Suitable ingredients (Objects) to use in the crafting. - return_list (bool, optional): Always return a list, even if zero or one items were - cracted. raise_exception (bool, optional): If crafting failed for whatever reason, raise `CraftingError`. The user will still be informed by the recipe. **kwargs: Optional kwargs to pass into the recipe (will passed into recipe.craft). @@ -792,3 +850,115 @@ def craft(crafter, recipe_name, *inputs, return_list=True, raise_exception=False f"has a name matching {recipe_name}") recipe = RecipeClass(crafter, *inputs, **kwargs) return recipe.craft(raise_exception=raise_exception) + + +# craft command/cmdset + +class CraftingCmdSet(CmdSet): + """ + Store crafting command. + """ + key = "Crafting cmdset" + + def at_cmdset_creation(self): + self.add(CmdCraft()) + + +class CmdCraft(Command): + """ + Craft an item using ingredients and tools + + Usage: + craft [from ,...] [using , ...] + + Examples: + craft snowball from snow + craft puppet from piece of wood using knife + craft bread from flour, butter, water, yeast using owen, bowl, roller + craft fireball using wand, spellbook + + Notes: + Ingredients must be in the crafter's inventory. Tools can also be + things in the current location, like a furnace, windmill or anvil. + + """ + + def parse(self): + """ + Handle parsing of + :: + + [FROM ] [USING ] + + """ + self.args = args = self.args.strip().lower() + recipe, ingredients, tools = "", "", "" + + if 'from' in args: + recipe, *rest = args.split(" from ", 1) + rest = rest[0] if rest else "" + ingredients, *tools = rest.split(" using ", 1) + elif 'using' in args: + recipe, *tools = args.split(" using ", 1) + tools = tools[0] if tools else "" + + self.recipe = recipe.strip() + self.ingredients = [ingr.strip() for ingr in ingredients.split(",")] + self.tools = [tool.strip() for tool in tools.split(",")] + + def func(self): + """ + Perform crafting. + + Will check the `craft` locktype. If a consumable/ingredient does not pass + this check, we will check for the 'crafting_consumable_err_msg' + Attribute, otherwise will use a default. If failing on a tool, will use + the `crafting_tool_err_msg` if available. + + """ + caller = self.caller + + if not self.args or not self.recipe: + self.caller.msg("Usage: craft from , ... [using ,...]") + return + + ingredients = [] + for ingr_key in self.ingredients: + if not ingr_key: + continue + obj = caller.search(ingr_key, location=self.caller) + # since ingredients are consumed we need extra check so we don't + # try to include characters or accounts etc. + if not obj: + return + if (not inherits_from(obj, "evennia.objects.models.ObjectDB") + or obj.sessions.all() or not obj.access(caller, "craft", default=True)): + # We don't allow to include puppeted objects nor those with the + # 'negative' permission 'nocraft'. + caller.msg(obj.attributes.get( + "crafting_consumable_err_msg", + default=f"{obj.get_display_name(looker=caller)} can't be used for this.")) + return + ingredients.append(obj) + + tools = [] + for tool_key in self.tools: + if not tool_key: + continue + # tools are not consumed, can also exist in the current room + obj = caller.search(tool_key) + if not obj: + return None + if not obj.access(caller, "craft", default=True): + caller.msg(obj.attributes.get( + "crafting_tool_err_msg", + default=f"{obj.get_display_name(looker=caller)} can't be used for this.")) + return + tools.append(obj) + + # perform craft and make sure result is in inventory + # (the recipe handles all returns to caller) + result = craft(caller, self.recipe, *(tools + ingredients)) + if result: + for obj in result: + obj.location = caller diff --git a/evennia/contrib/crafting/example_recipes.py b/evennia/contrib/crafting/example_recipes.py index 1f724cb4c7..c5431f7452 100644 --- a/evennia/contrib/crafting/example_recipes.py +++ b/evennia/contrib/crafting/example_recipes.py @@ -32,10 +32,10 @@ around with them. sword guard = crucible steel + hammer[T] + anvil[T] + furnace[T] rawhide = fur + knife[T] - oak bark = oak wood + knife[T] + oak bark + cleaned oak wood = oak wood + knife[T] leather = rawhide + oak bark + water + cauldron[T] - sword handle = oak wood + knife[T] + sword handle = cleaned oak wood + knife[T] sword = sword blade + sword guard + sword pommel + sword handle + leather + knife[T] + hammer[T] + furnace[T] @@ -90,7 +90,7 @@ class _SwordSmithingBaseRecipe(CraftingRecipe): failed_message = ("You work and work but you are not happy with the result. " "You need to start over.") - def do_craft(self, validated_inputs, **kwargs): + def do_craft(self, **kwargs): """ Making a sword blade takes skill. Here we emulate this by introducing a random chance of failure (in a real game this could be a skill check @@ -117,7 +117,7 @@ class _SwordSmithingBaseRecipe(CraftingRecipe): if random.random() < 0.8: # 80% chance of success. This will spawn the sword and show # success-message. - return super().do_craft() + return super().do_craft(**kwargs) else: # fail and show failed message return None @@ -161,7 +161,7 @@ class SwordGuardRecipe(_SwordSmithingBaseRecipe): sword's blade and also protects the hand when parrying. """ - name = "sword pommel" + name = "sword guard" tool_tags = ["hammer", "anvil", "furnace"] consumable_tags = ["crucible steel"] output_prototypes = [ @@ -191,6 +191,7 @@ class OakBarkRecipe(CraftingRecipe): The actual thing needed for tanning leather is Tannin, but we skip the step of refining tannin from the bark and use the bark as-is. + This produces two outputs - the bark and the cleaned wood. """ name = "oak bark" tool_tags = ["knife"] @@ -198,7 +199,11 @@ class OakBarkRecipe(CraftingRecipe): output_prototypes = [ {"key": "Oak bark", "desc": "Bark of oak, stripped from the core wood.", - "tags": [("oak bark", "crafting_material")]} + "tags": [("oak bark", "crafting_material")]}, + {"key": "Oak Wood (cleaned)", + "desc": "Oakwood core, stripped of bark.", + "tags": [("cleaned oak wood", "crafting_material")]}, + ] @@ -228,7 +233,7 @@ class SwordHandleRecipe(CraftingRecipe): """ name = "sword handle" tool_tags = ["knife"] - consumable_tags = ["oak wood"] + consumable_tags = ["cleaned oak wood"] output_prototypes = [ {"key": "Sword handle", "desc": "Two pieces of wood to be be fitted onto a sword's tang as its handle.", diff --git a/evennia/contrib/crafting/tests.py b/evennia/contrib/crafting/tests.py new file mode 100644 index 0000000000..f7727c30af --- /dev/null +++ b/evennia/contrib/crafting/tests.py @@ -0,0 +1,672 @@ +""" +Unit tests for the crafting system contrib. + +""" + +from unittest import mock +from anything import Something +from django.test import override_settings +from django.core.exceptions import ObjectDoesNotExist +from evennia.commands.default.tests import CommandTest +from evennia.utils.test_resources import TestCase, EvenniaTest +from evennia.utils.create import create_object +from . import crafting, example_recipes + + +class TestCraftUtils(TestCase): + """ + Test helper utils for crafting. + + """ + maxDiff = None + + @override_settings(CRAFT_RECIPE_MODULES=[]) + def test_load_recipes(self): + """This should only load the example module now""" + + crafting._load_recipes() + self.assertEqual( + crafting._RECIPE_CLASSES, + { + 'crucible steel': example_recipes.CrucibleSteelRecipe, + 'leather': example_recipes.LeatherRecipe, + 'oak bark': example_recipes.OakBarkRecipe, + 'pig iron': example_recipes.PigIronRecipe, + 'rawhide': example_recipes.RawhideRecipe, + 'sword': example_recipes.SwordRecipe, + 'sword blade': example_recipes.SwordBladeRecipe, + 'sword guard': example_recipes.SwordGuardRecipe, + 'sword handle': example_recipes.SwordHandleRecipe, + 'sword pommel': example_recipes.SwordPommelRecipe, + } + ) + + +class _TestMaterial: + def __init__(self, name): + self.name = name + + def __repr__(self): + return self.name + + +class TestCraftingRecipeBase(TestCase): + """ + Test the parent recipe class. + """ + def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock() + + self.inp1 = _TestMaterial("test1") + self.inp2 = _TestMaterial("test2") + self.inp3 = _TestMaterial("test3") + + self.kwargs = {"kw1": 1, "kw2": 2} + + self.recipe = crafting.CraftingRecipeBase( + self.crafter, self.inp1, self.inp2, self.inp3, **self.kwargs) + + def test_msg(self): + """Test messaging to crafter""" + + self.recipe.msg("message") + self.crafter.msg.assert_called_with("message", {"type": "crafting"}) + + def test_pre_craft(self): + """Test validating hook""" + self.recipe.pre_craft() + self.assertEqual( + self.recipe.validated_inputs, (self.inp1, self.inp2, self.inp3) + ) + + def test_pre_craft_fail(self): + """Should rase error if validation fails""" + self.recipe.allow_craft = False + with self.assertRaises(crafting.CraftingValidationError): + self.recipe.pre_craft() + + def test_craft_hook__succeed(self): + """Test craft hook, the main access method.""" + + expected_result = _TestMaterial("test_result") + self.recipe.do_craft = mock.MagicMock(return_value=expected_result) + + self.assertTrue(self.recipe.allow_craft) + + result = self.recipe.craft() + + # check result + self.assertEqual(result, expected_result) + self.recipe.do_craft.assert_called_with(kw1=1, kw2=2) + + # since allow_reuse is False, this usage should now be turned off + self.assertFalse(self.recipe.allow_craft) + # trying to re-run again should fail since rerun is False + with self.assertRaises(crafting.CraftingError): + self.recipe.craft() + + def test_craft_hook__fail(self): + """Test failing the call""" + + self.recipe.do_craft = mock.MagicMock(return_value=None) + + # trigger exception + with self.assertRaises(crafting.CraftingError): + self.recipe.craft(raise_exception=True) + + # reset and try again without exception + self.recipe.allow_craft = True + result = self.recipe.craft() + self.assertEqual(result, None) + + +class _MockRecipe(crafting.CraftingRecipe): + name = "testrecipe" + tool_tags = ["tool1", "tool2"] + consumable_tags = ["cons1", "cons2", "cons3"] + output_prototypes = [ + {"key": "Result1", + "prototype_key": "resultprot", + "tags": [("result1", "crafting_material")]} + ] + + +@override_settings(CRAFT_RECIPE_MODULES=[]) +class TestCraftingRecipe(TestCase): + """ + Test the CraftingRecipe class with one recipe + """ + maxDiff = None + + def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock() + + self.tool1 = create_object(key="tool1", tags=[("tool1", "crafting_tool")], nohome=True) + self.tool2 = create_object(key="tool2", tags=[("tool2", "crafting_tool")], nohome=True) + self.cons1 = create_object(key="cons1", tags=[("cons1", "crafting_material")], nohome=True) + self.cons2 = create_object(key="cons2", tags=[("cons2", "crafting_material")], nohome=True) + self.cons3 = create_object(key="cons3", tags=[("cons3", "crafting_material")], nohome=True) + + def tearDown(self): + try: + self.tool1.delete() + self.tool2.delete() + self.cons1.delete() + self.cons2.delete() + self.cons3.delete() + except ObjectDoesNotExist: + pass + + def test_error_format(self): + """Test the automatic error formatter """ + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3 + ) + + msg = ("{missing},{tools},{consumables},{inputs},{outputs}" + "{i0},{i1},{o0}") + kwargs = {"missing": "foo", "tools": ["bar", "bar2", "bar3"], + "consumables": ["cons1", "cons2"]} + + expected = { + 'missing': 'foo', 'i0': 'cons1', 'i1': 'cons2', 'i2': 'cons3', 'o0': + 'Result1', 'tools': 'bar, bar2 and bar3', 'consumables': 'cons1 and cons2', + 'inputs': 'cons1, cons2 and cons3', 'outputs': 'Result1'} + + result = recipe._format_message(msg, **kwargs) + self.assertEqual(result, msg.format(**expected)) + + def test_craft__success(self): + """Test to create a result from the recipe""" + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3 + ) + + result = recipe.craft() + + 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"}) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_seed__succcess(self): + """Test seed helper classmethod""" + + # call classmethod directly + tools, consumables = _MockRecipe.seed() + + # this should be a normal successful crafting + recipe = _MockRecipe( + self.crafter, + *(tools + consumables) + ) + + result = recipe.craft() + + 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"}) + + # make sure consumables are gone + for cons in consumables: + self.assertIsNone(cons.pk) + # make sure tools remain + for tool in tools: + self.assertIsNotNone(tool.pk) + + def test_craft_missing_tool__fail(self): + """Fail craft by missing tool2""" + recipe = _MockRecipe( + self.crafter, + self.tool1, self.cons1, self.cons2, self.cons3 + ) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_tool_missing_message.format(outputs="Result1", missing='tool2'), + {"type": "crafting"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_missing_cons__fail(self): + """Fail craft by missing cons3""" + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2 + ) + result = recipe.craft() + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_missing_message.format( + outputs="Result1", missing='cons3'), + {"type": "crafting"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_missing_cons__always_consume__fail(self): + """Fail craft by missing cons3, with always-consume flag""" + + cons4 = create_object(key="cons4", tags=[("cons4", "crafting_material")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, cons4 + ) + recipe.consume_on_fail = True + + result = recipe.craft() + + self.assertFalse(result) + self.crafter.msg.assert_called_with( + recipe.error_consumable_missing_message.format( + outputs="Result1", missing='cons3'), + {"type": "crafting"}) + + # make sure consumables are deleted even though we failed + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + # the extra should also be gone + self.assertIsNone(cons4.pk) + # but cons3 should be fine since it was not included + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain as normal + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_wrong_tool__fail(self): + """Fail craft by including a wrong tool""" + + wrong = create_object(key="wrong", tags=[("wrongtool", "crafting_tool")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, wrong + ) + 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"}) + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_tool_excess__fail(self): + """Fail by too many consumables""" + + # note that this is a valid tag! + tool3 = create_object(key="tool3", tags=[("tool2", "crafting_tool")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, tool3 + ) + 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"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + self.assertIsNotNone(tool3.pk) + + def test_craft_cons_excess__fail(self): + """Fail by too many consumables""" + + # note that this is a valid tag! + cons4 = create_object(key="cons4", tags=[("cons3", "crafting_material")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, cons4 + ) + 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"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + self.assertIsNotNone(cons4.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_tool_excess__sucess(self): + """Allow too many consumables""" + + tool3 = create_object(key="tool3", tags=[("tool2", "crafting_tool")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, tool3 + ) + recipe.exact_tools = False + result = recipe.craft() + self.assertTrue(result) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_cons_excess__sucess(self): + """Allow too many consumables""" + + cons4 = create_object(key="cons4", tags=[("cons3", "crafting_material")], nohome=True) + + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons1, self.cons2, self.cons3, cons4 + ) + recipe.exact_consumables = False + result = recipe.craft() + self.assertTrue(result) + self.crafter.msg.assert_called_with( + recipe.success_message.format(outputs="Result1"), {"type": "crafting"}) + + # make sure consumables are gone + self.assertIsNone(self.cons1.pk) + self.assertIsNone(self.cons2.pk) + self.assertIsNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_tool_order__fail(self): + """Strict tool-order recipe fail """ + recipe = _MockRecipe( + self.crafter, + self.tool2, self.tool1, self.cons1, self.cons2, self.cons3 + ) + recipe.exact_tool_order = True + 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"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + def test_craft_cons_order__fail(self): + """Strict tool-order recipe fail """ + recipe = _MockRecipe( + self.crafter, + self.tool1, self.tool2, self.cons3, self.cons2, self.cons1 + ) + recipe.exact_consumable_order = True + 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"}) + + # make sure consumables are still there + self.assertIsNotNone(self.cons1.pk) + self.assertIsNotNone(self.cons2.pk) + self.assertIsNotNone(self.cons3.pk) + # make sure tools remain + self.assertIsNotNone(self.tool1.pk) + self.assertIsNotNone(self.tool2.pk) + + +class TestCraftSword(TestCase): + """ + Test the `craft` function by crafting the example sword. + + """ + def setUp(self): + self.crafter = mock.MagicMock() + self.crafter.msg = mock.MagicMock() + + @override_settings(CRAFT_RECIPE_MODULES=[], DEFAULT_HOME="#999999") + @mock.patch("evennia.contrib.crafting.example_recipes.random") + def test_craft_sword(self, mockrandom): + """ + Craft example sword. For the test, every crafting works. + + """ + # make sure every craft succeeds + mockrandom.random = mock.MagicMock(return_value=0.2) + + def _co(key, tagkey, is_tool=False): + tagcat = "crafting_tool" if is_tool else "crafting_material" + return create_object(key=key, tags=[(tagkey, tagcat)], nohome=True) + + def _craft(recipe_name, *inputs): + """shortcut to shorten and return only one element""" + result = crafting.craft(self.crafter, recipe_name, *inputs, raise_exception=True) + return result[0] if len(result) == 1 else result + + # generate base materials + iron_ore1 = _co("Iron ore ingot", "iron ore") + iron_ore2 = _co("Iron ore ingot", "iron ore") + iron_ore3 = _co("Iron ore ingot", "iron ore") + + ash1 = _co("Pile of Ash", "ash") + ash2 = _co("Pile of Ash", "ash") + ash3 = _co("Pile of Ash", "ash") + + sand1 = _co("Pile of sand", "sand") + sand2 = _co("Pile of sand", "sand") + sand3 = _co("Pile of sand", "sand") + + coal01 = _co("Pile of coal", "coal") + coal02 = _co("Pile of coal", "coal") + coal03 = _co("Pile of coal", "coal") + coal04 = _co("Pile of coal", "coal") + coal05 = _co("Pile of coal", "coal") + coal06 = _co("Pile of coal", "coal") + coal07 = _co("Pile of coal", "coal") + coal08 = _co("Pile of coal", "coal") + coal09 = _co("Pile of coal", "coal") + coal10 = _co("Pile of coal", "coal") + coal11 = _co("Pile of coal", "coal") + coal12 = _co("Pile of coal", "coal") + + oak_wood = _co("Pile of oak wood", "oak wood") + water = _co("Bucket of water", "water") + fur = _co("Bundle of Animal fur", "fur") + + # tools + blast_furnace = _co("Blast furnace", "blast furnace", is_tool=True) + furnace = _co("Smithing furnace", "furnace", is_tool=True) + crucible = _co("Smelting crucible", "crucible", is_tool=True) + anvil = _co("Smithing anvil", "anvil", is_tool=True) + hammer = _co("Smithing hammer", "hammer", is_tool=True) + knife = _co("Working knife", "knife", is_tool=True) + cauldron = _co("Cauldron", "cauldron", is_tool=True) + + # making pig iron + inputs = [iron_ore1, coal01, coal02, blast_furnace] + pig_iron1 = _craft("pig iron", *inputs) + + inputs = [iron_ore2, coal03, coal04, blast_furnace] + pig_iron2 = _craft("pig iron", *inputs) + + inputs = [iron_ore3, coal05, coal06, blast_furnace] + pig_iron3 = _craft("pig iron", *inputs) + + # making crucible steel + inputs = [pig_iron1, ash1, sand1, coal07, coal08, crucible] + crucible_steel1 = _craft("crucible steel", *inputs) + + inputs = [pig_iron2, ash2, sand2, coal09, coal10, crucible] + crucible_steel2 = _craft("crucible steel", *inputs) + + inputs = [pig_iron3, ash3, sand3, coal11, coal12, crucible] + crucible_steel3 = _craft("crucible steel", *inputs) + + # smithing + inputs = [crucible_steel1, hammer, anvil, furnace] + sword_blade = _craft("sword blade", *inputs) + + inputs = [crucible_steel2, hammer, anvil, furnace] + sword_pommel = _craft("sword pommel", *inputs) + + inputs = [crucible_steel3, hammer, anvil, furnace] + sword_guard = _craft("sword guard", *inputs) + + # stripping fur + inputs = [fur, knife] + rawhide = _craft("rawhide", *inputs) + + # making bark (tannin) and cleaned wood + inputs = [oak_wood, knife] + oak_bark, cleaned_oak_wood = _craft("oak bark", *inputs) + + # leathermaking + inputs = [rawhide, oak_bark, water, cauldron] + leather = _craft("leather", *inputs) + + # sword handle + inputs = [cleaned_oak_wood, knife] + sword_handle = _craft("sword handle", *inputs) + + # sword (order matters) + inputs = [sword_blade, sword_guard, sword_pommel, sword_handle, + leather, knife, hammer, furnace] + sword = _craft("sword", *inputs) + + self.assertEqual(sword.key, "Sword") + + # make sure all materials and intermediaries are deleted + self.assertIsNone(iron_ore1.pk) + self.assertIsNone(iron_ore2.pk) + self.assertIsNone(iron_ore3.pk) + self.assertIsNone(ash1.pk) + self.assertIsNone(ash2.pk) + self.assertIsNone(ash3.pk) + self.assertIsNone(sand1.pk) + self.assertIsNone(sand2.pk) + self.assertIsNone(sand3.pk) + self.assertIsNone(coal01.pk) + self.assertIsNone(coal02.pk) + self.assertIsNone(coal03.pk) + self.assertIsNone(coal04.pk) + self.assertIsNone(coal05.pk) + self.assertIsNone(coal06.pk) + self.assertIsNone(coal07.pk) + self.assertIsNone(coal08.pk) + self.assertIsNone(coal09.pk) + self.assertIsNone(coal10.pk) + self.assertIsNone(coal11.pk) + self.assertIsNone(coal12.pk) + self.assertIsNone(oak_wood.pk) + self.assertIsNone(water.pk) + self.assertIsNone(fur.pk) + self.assertIsNone(pig_iron1.pk) + self.assertIsNone(pig_iron2.pk) + self.assertIsNone(pig_iron3.pk) + self.assertIsNone(crucible_steel1.pk) + self.assertIsNone(crucible_steel2.pk) + self.assertIsNone(crucible_steel3.pk) + self.assertIsNone(sword_blade.pk) + self.assertIsNone(sword_pommel.pk) + self.assertIsNone(sword_guard.pk) + self.assertIsNone(rawhide.pk) + self.assertIsNone(oak_bark.pk) + self.assertIsNone(leather.pk) + self.assertIsNone(sword_handle.pk) + + # make sure all tools remain + self.assertIsNotNone(blast_furnace) + self.assertIsNotNone(furnace) + self.assertIsNotNone(crucible) + self.assertIsNotNone(anvil) + self.assertIsNotNone(hammer) + self.assertIsNotNone(knife) + self.assertIsNotNone(cauldron) + + +@mock.patch("evennia.contrib.crafting.crafting._load_recipes", + new=mock.MagicMock()) +@mock.patch("evennia.contrib.crafting.crafting._RECIPE_CLASSES", + new={"testrecipe": _MockRecipe}) +@override_settings(CRAFT_RECIPE_MODULES=[], DEFAULT_HOME="#999999") +class TestCraftCommand(CommandTest): + """Test the crafting command""" + + def setUp(self): + super().setUp() + + tools, consumables = _MockRecipe.seed( + tool_kwargs={"location": self.char1}, + consumable_kwargs={"location": self.char1}) + + def test_craft__success(self): + "Successfully craft using command" + self.call( + crafting.CmdCraft(), + "testrecipe from cons1, cons2, cons3 using tool1, tool2", + _MockRecipe.success_message.format(outputs="Result1") + ) + + def test_craft__notools__failure(self): + "Craft fail no tools" + self.call( + crafting.CmdCraft(), + "testrecipe from cons1, cons2, cons3", + _MockRecipe.error_tool_missing_message.format(outputs="Result1", missing="tool1") + ) + + def test_craft__nocons__failure(self): + self.call( + crafting.CmdCraft(), + "testrecipe using tool1, tool2", + _MockRecipe.error_consumable_missing_message.format(outputs="Result1", missing="cons1") + ) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index a380e5217e..f4ee9929cb 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -405,7 +405,9 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): to search. Note that this is used to query the *contents* of a location and will not match for the location itself - if you want that, don't set this or use `candidates` to specify - exactly which objects should be searched. + exactly which objects should be searched. If this nor candidates are + given, candidates will include caller's inventory, current location and + all objects in the current location. attribute_name (str): Define which property to search. If set, no key+alias search will be performed. This can be used to search database fields (db_ will be automatically diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 940f3721e1..0a5ec6012f 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -915,8 +915,15 @@ def spawn(*prototypes, **kwargs): val = prot.pop("location", None) create_kwargs["db_location"] = init_spawn_value(val, value_to_obj) - val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = init_spawn_value(val, value_to_obj) + val = prot.pop("home", None) + if val: + create_kwargs["db_home"] = init_spawn_value(val, value_to_obj) + else: + try: + create_kwargs["db_home"] = init_spawn_value(settings.DEFAULT_HOME, value_to_obj) + except ObjectDB.DoesNotExist: + # settings.DEFAULT_HOME not existing is common for unittests + pass val = prot.pop("destination", None) create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj)