diff --git a/evennia/contrib/crafting/README.md b/evennia/contrib/crafting/README.md new file mode 100644 index 0000000000..3d5b40895c --- /dev/null +++ b/evennia/contrib/crafting/README.md @@ -0,0 +1,20 @@ +# Crafting system + +Contrib - Griatch 2020 + +This implements a full crafting system. The principle is that of a 'recipe': + + object1 + object2 + ... -> craft_recipe -> objectA, objectB, ... + +The recipe is a class that specifies input and output hooks. By default the +input is a list of object-tags (using the "crafting_material" tag-category) +and objects passing this check must be passed into the recipe. + +The output is given by a set of prototypes. If the input is correct and other +checks are passed (such as crafting skill, for example), these prototypes will +be used to generate the new objects being 'crafted'. + +Each recipe is a stand-alone entity which allows for very advanced customization +for every recipe - for example one could have a recipe where the input ingredients +are not destroyed in the process, or which require other properties of the input +(such as a 'quality'). diff --git a/evennia/contrib/crafting/crafting.py b/evennia/contrib/crafting/crafting.py new file mode 100644 index 0000000000..af7239f786 --- /dev/null +++ b/evennia/contrib/crafting/crafting.py @@ -0,0 +1,747 @@ +""" +Crafting - Griatch 2020 + +This is a general crafting engine. The basic functionality of crafting is to +combine any number of of items in a 'recipe' to produce a new result. This is +useful not only for traditional crafting but also for puzzle-solving or +similar. + +## Installation + +- Create a new module and add it to a new list in your settings file + (`server/conf/settings.py`) named `CRAFT_MODULE_RECIPES`. +- In the new module, create one or more classes, each a child of + `CraftingRecipe` from this module. Each such class must have a unique `.name` + property. It also defines what inputs are required and what is created using + this recipe. +- Objects to use for crafting should (by default) be tagged with tags using the + tag-category `crafting_material`. The name of the object doesn't matter, only + its tag. +- Add the `CmdCraft` command from this module to your default cmdset. This is a + very simple example-command (your real command will most likely need to do + skill-checks etc!). + +## Usage + +By default the crafter needs to specify which components +should be used for the recipe: + + craft spiked club from club, nails + +Here, `spiked club` specifies the recipe while `club` and `nails` are objects +the crafter must have in their inventory. These will be consumed during +crafting (by default only if crafting was successful). + +A recipe can also require _tools_. These must be either in inventory or in +the current location. Tools are not consumed during the crafting. + + craft wooden doll from wood with knife + +In code, you should use the helper function `craft` from this module. This +specifies the name of the recipe to use and expects all suitable +ingredients/tools as arguments (consumables and tools should be added together, +tools will be identified before consumables). + + spiked_club = craft(crafter, "spiked club", club, nails) + +A fail leads to an empty return. The crafter should already have been notified +of any error in this case (this should be handle by the recipe itself). + +## Recipes + +A _recipe_ works like an input/output blackbox: you put consumables (and/or +tools) into it and if they match the recipe, a new result is spit out. +Consumables are consumed in the process while tools are not. + +This module contains a base class for making new ingredient types +(`CraftingRecipeBase`) and an implementation of the most common form of +crafting (`CraftingRecipe`) using objects and prototypes. + +Recipes are put in one or more modules added as a list to the +`CRAFT_MODULE_RECIPES` setting, for example: + + 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. + +```python + + from evennia.contrib.crafting.crafting import CraftingRecipe + + class PigIronRecipe(CraftingRecipe): + # Pig iron is a high-carbon result of melting iron in a blast furnace. + + name = "pig iron" + tool_tags = ["blast furnace"] + consumable_tags = ["iron ore", "coal", "coal"] + output_prototypes = [ + {"key": "Pig Iron ingot", + "desc": "An ingot of crude pig iron.", + "tags": [("pig iron", "crafting_material")]} + ] + +``` + +The `evennia/contrib/crafting/example_recipes.py` module has more examples of +recipes. + +---- + +""" + +from copy import copy +from evennia.utils.utils import ( + iter_to_str, callables_from_module, inherits_from) +from evennia.prototypes.spawner import spawn +from evennia.utils.create import create_object + +_RECIPE_CLASSES = {} + + +def _load_recipes(): + """ + Delayed loading of recipe classes. This parses + `settings.CRAFT_RECIPE_MODULES`. + + """ + from django.conf import settings + global _RECIPE_CLASSES + if not _RECIPE_CLASSES: + for path in settings.CRAFT_RECIPE_MODULES: + for cls in callables_from_module(path): + if inherits_from(cls, CraftingRecipe): + _RECIPE_CLASSES[cls.name] = cls + + +class CraftingError(RuntimeError): + """ + Crafting error. + + """ + +class CraftingRecipeBase: + """ + This is the base of the crafting system. The recipe handles all aspects of + performing a 'craft' operation. + + Example of usage: + :: + + recipe = CraftRecipe(crafter, obj1, obj2, obj3) + result = recipe.craft() + + Note that the most common crafting operation is that the inputs are + consumed - so in that case the recipe cannot be used a second time (doing so + will raise a `CraftingError`) + + """ + name = "recipe base" + + # if set, allow running `.craft` more than once on the same instance. + # don't set this unless crafting inputs are *not* consumed by the crafting + # process (otherwise subsequent calls will fail). + allow_reuse = False + + def __init__(self, crafter, *inputs, **kwargs): + """ + Initialize the recipe. + + Args: + crafter (Object): The one doing the crafting. + *inputs (any): The ingredients of the recipe to use. + **kwargs (any): Any other parameters that are relevant for + this recipe. + + """ + self.crafter = crafter + self.inputs = self.inputs + self.craft_kwargs = kwargs + self.allow_craft = True + + def msg(self, message, **kwargs): + """ + Send message to crafter. This is a central point to override if wanting + to change crafting return style in some way. + + Args: + message(str): The message to send. + **kwargs: Any optional properties relevant to this send. + + """ + self.crafter.msg(message, {"type": "crafting"}) + + def validate_inputs(self, *inputs, **kwargs): + """ + Hook to override. + + Make sure the provided inputs are valid. This should always be run. + + Args: + inputs (any): Items to be tried. . + Returns: + list or None: Return whichever items were validated (some recipes + may allow for partial/too many ingredients) or `None` if validation failed. + + Note: + This method is also responsible for properly sending error messages + to e.g. self.crafter (usually via `self.msg`). + + """ + if self.allow_craft: + return self.inputs[:] + + def pre_craft(self, validated_inputs, **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`. + **kwargs (any): Passed from `self.craft`. + + Returns: + any: The validated_inputs, modified or not. + + """ + if not validated_inputs: + raise CraftingError() + + return validated_inputs + + def do_craft(self, validated_inputs, **kwargs): + """ + Hook to override. + + This performs the actual crafting. At this point the inputs are + expected to have been verified already. + + Args: + validated_inputs (any): Data previously returned from `pre_craft`. + kwargs (any): Passed from `self.craft`. + + Returns: + any: The result of the crafting. + + """ + pass + + def post_craft(self, validated_inputs, 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: + validated_inputs (any): The inputs used as part of the crafting. + craft_result (any): 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. + + + """ + return craft_result + + def craft(self, raise_exception=False, **kwargs): + """ + Main crafting call method. Call this to produce a result and make + sure all hooks run correctly. + + Args: + raise_exception (bool): If crafting would return `None`, raise + exception instead. + **kwargs (any): Any other parameters that is relevant + for this particular craft operation. This will temporarily + override same-named kwargs given at the creation of this recipe + and be passed into all of the crafting hooks. + + Returns: + any: The result of the craft, or `None` if crafting failed. + + Raises: + CraftingError: If crafting would return `None` and raise_exception` + is set. + + """ + craft_result = None + err = "" + if self.allow_craft: + craft_kwargs = copy(self.craft_kwargs) + craft_kwargs.update(kwargs) + + try: + # this assigns to self.validated_inputs + validated_inputs = self.validate_inputs(*self.inputs, **craft_kwargs) + + # run the crafting process + self.pre_craft(validated_inputs, **craft_kwargs) + craft_result = self.do_craft(validated_inputs, **craft_kwargs) + craft_result = self.post_craft(validated_inputs, craft_result, **craft_kwargs) + except CraftingError as exc: + # use this to abort crafting early + if exc.message: + self.msg(exc.message) + # possibly turn off re-use depending on class setting + self.allow_craft = self.allow_reuse + else: + err = "Cannot re-run crafting without refreshing recipe first." + if craft_result is None and raise_exception: + raise CraftingError(err) + return craft_result + + +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. + + 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'. + + Examples: + :: + + class SwordRecipe(CraftRecipe): + name = "sword" + input_tags = ["hilt", "pommel", "strips of leather", "sword blade"] + output_prototypes = [ + {"key": "sword", + "typeclass": "typeclassess.weapons.bladed.Sword", + "tags": [("sword", "weapon"), ("melee", "weapontype"), + ("edged", "weapontype")] + } + ] + + ## Properties on the class level: + + - `name` (str): The name of this recipe. This should be globally unique. + - `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`. + - `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 + right order for crafting to pass. + + ### consumables + + - `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. + - `exact_consumables` (bool, default True): Normally, adding more consumables + than needed leads to a a crafting error. If this is False, the craft will + still succeed (only the needed ingredients will be consumed). + - `exact_consumable_order` (bool, default False): Normally, the order in which + ingredients are added does not matter. With this set, trying to add consumables in + 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. + + ### outputs (result of crafting) + + - `output_prototypes` (list): One or more prototypes (`prototype_keys` or + full dicts) describing how to create the result(s) of this recipe. + - `output_names` (list): Human-readable names for (prospective) prototypes. + This is used in error messages. If not given, this is extracted from the + prototypes' `key` if possible. + + ### custom error messages + + custom messages all have custom formatting markers (default strings are shown): + + {missing}: Comma-separated list of components missing for missing/out of order errors. + {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. + {outputs}: Comma-separated list of (expected) outputs + {t0}..{tN-1}: Individual tools, same order as `.tool_names`. + {c0}..{cN-1}: Individual consumables, same order as `.consumable_names`. + {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_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." + - `success_message`: "You successfuly craft {outputs}!" + - `failed_message`: "You failed to craft {outputs}." + + ## 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. + + Use `.msg` to conveniently send messages to the crafter. Raise + `evennia.contrib.crafting.crafting.CraftingError` exception to abort + crafting at any time in the sequence. If raising with a text, this will be + shown to the crafter automatically + + """ + name = "crafting recipe" + + # this define the overall category all material tags must have + consumable_tag_category = "crafting_material" + # tag category for tool objects + tool_tag_category = "crafting_tool" + + # the tools needed to perform this crafting. Tools are never consumed (if they were, + # they'd need to be a consumable). If more than one instance of a tool is needed, + # there should be multiple entries in this list. + tool_tags = [] + # human-readable names for the tools. This will be used for informative messages + # or when usage fails. If empty + tool_names = [] + # if we must have exactly the right tools, no more + exact_tools = True + # if the order of the tools matters + exact_tool_order = False + # error to show if missing tools + error_tool_missing_message = "Could not craft {outputs} without {missing}." + # error to show if tool-order matters and it was wrong. Missing is the first + # tool out of order + error_tool_order_message = \ + "Could not craft {outputs} since {missing} was added in the wrong order." + + # 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. + consumable_tags = [] + # these are human-readable names for the items to use. This is used for informative + # messages or when usage fails. If empty, the tag-names will be used. If given, this + # must have the same length as `consumable_tags`. + consumable_names = [] + # if True, consume valid inputs also if crafting failed (returned None) + consume_on_fail = False + # if True, having any wrong input result in failing the crafting. If False, + # extra components beyond the recipe are ignored. + exact_consumables = True + # if True, the exact order in which inputs are provided matters and must match + # the order of `consumable_tags`. If False, order doesn't matter. + exact_consumable_order = False + # error to show if missing consumables + error_consumable_missing_message = "Could not craft {outputs} without {missing}." + # error to show if consumable order matters and it was wrong. Missing is the first + # consumable out of order + error_consumable_order_message = \ + "Could not craft {outputs} since {missing} was added in the wrong order." + + # 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 + # these will be returned (as a list) if crafting succeeded. + output_prototypes = [] + # human-readable name(s) for the (expected) result of this crafting. This will usually only + # be used for error messages (to report what would have been). If not given, the + # prototype's key or typeclass will be used. If given, this must have the same length + # as `output_prototypes`. + output_names = [] + + success_message = "You successfully craft {outputs}!" + # custom craft-failure. + failed_message = "Failed to craft {outputs}." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.consumable_names: + assert len(self.consumable_names) == len(self.consumable_tags), \ + "Crafting .consumable_names list must have the same length as .consumable_tags." + else: + self.consumable_names = self.consumable_tags + if self.output_names: + assert len(self.consumable_names) == len(self.consumable_tags), \ + "Crafting .output_names list must have the same length as .output_prototypes." + else: + self.output_names = [ + prot.get("key", prot.get("typeclass"), "unnamed") + for prot in self.output_prototypes] + + self.allow_reuse = not self.consume_inputs + + def _format_message(self, message, **kwargs): + + missing = iter_to_str(kwargs.get("missing", "nothing")) + + # build template context + mapping = {"missing": iter_to_str(missing)} + mapping.update({ + f"i{ind}": self.consumable_names[ind] + for ind, name in enumerate(self.consumable_names.values()) + }) + mapping.update({ + f"o{ind}": self.output_names[ind] + for ind, name in enumerate(self.output_names.values()) + }) + mapping["inputs"] = iter_to_str(self.consumable_names) + mapping["outputs"] = iter_to_str(self.output_names) + + # populate template and return + return message.format(**mapping) + + def seed(self, 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. + + Args: + consumable_kwargs (dict, optional): This will be passed as + `**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` + call for each tool. If not given, the matching + `tool_name` or `tool_tag` will be used for key. + + Returns: + tuple: A tuple `(tools, consumables)` with newly created dummy + objects matching the recipe ingredient list. + + Notes: + If `key` is given in `consumable/tool_kwargs` then _every_ created item + of each type will have the same key. + + """ + if not tool_kwargs: + tool_kwargs = {} + if not consumable_kwargs: + consumable_kwargs = {} + tool_key = tool_kwargs.pop("key", None) + cons_key = consumable_kwargs.pop("key", None) + tool_tags = tool_kwargs.pop("tags", []) + cons_tags = consumable_kwargs.pop("tags", []) + + tools = [] + for itag, tag in enumerate(self.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], + **tool_kwargs + ) + ) + consumables = [] + for itag, tag in enumerate(self.consumable_tags): + consumables.append( + create_object( + key=cons_key or (self.consumable_names[itag] if + self.consumable_names else + tag.capitalize()), + tags=[(tag, self.consumable_tag_category), *cons_tags] + + ) + ) + return tools, consumables + + def validate_inputs(self, *inputs, **kwargs): + """ + Check so the given inputs are what is needed. + + Note that on successful validation we return a tuple `(tools, consumables)`. + + """ + + def _check_completeness( + tagmap, taglist, namelist, exact_match, exact_order, + error_missing_message, error_order_message): + """Compare tagmap to taglist""" + valids = [] + for itag, tagkey in enumerate(taglist): + found_obj = None + for obj, taglist in tagmap.items(): + if tagkey in taglist: + 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())) + return [] + if exact_order: + # if we get here order is wrong + self.msg(self._format_message( + error_order_message, + missing=namelist[itag] if namelist else tagkey.capitalize())) + return [] + + # since we pop from the mapping, it gets ever shorter + match = tagmap.pop(found_obj, None) + if match: + valids.append(match) + return valids + + # get tools and consumables from inputs from + tool_map = {obj: obj.tags.get(category=self.tool_tag_category, return_list=True) + for obj in inputs if obj and hasattr(obj, "tags")} + consumable_map = {obj: obj.tags.get(category=self.tag_category, return_list=True) + for obj in inputs + if obj and hasattr(obj, "tags") and obj not in tool_map} + + tools = _check_completeness( + tool_map, + self.tool_tags, + self.tool_names, + self.exact_tools, + self.exact_tool_order, + self.error_tool_message, + self.error_tool_order_message + ) + consumables = _check_completeness( + consumable_map, + self.consumable_tags, + self.consumable_names, + self.exact_consumables, + self.exact_consumable_order, + self.error_consumable_missing_message, + self.error_consumable_order_message + ) + # regardless of flags, the tools/consumable lists much contain exactly + # all the recipe needs now. + if (len(tools) == len(self.tool_tags) and len(consumables) == len(self.consumable_tags)): + return tools, consumables + return None + + # including also empty hooks here for easier reference + + def pre_craft(self, validated_inputs, **kwargs): + """ + Hook to override. + + This is called just before crafting operation, after inputs have + been validated. + + Args: + validated_inputs (tuple): 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. + + """ + if not validated_inputs: + # abort crafting here, remove if wanting some other action + raise CraftingError(f"Crafting validation error {self.name}") + + return validated_inputs + + def do_craft(self, validated_inputs, **kwargs): + """ + Hook to override. + + 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. + + Notes: + We may want to analyze the tools in some way here to affect the + crafting process. + + """ + return spawn(*self.output_prototypes) + + def post_craft(self, validated_inputs, 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: + validated_inputs (tuple): the validated inputs, a tuple `(tools, consumables)`. + craft_result (any): 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. + + + """ + consume = self.consume_inputs + + _, consumables = validated_inputs or (None, None) + + if craft_result: + self.msg(self._format_message(self.success_message)) + else: + self.msg(self._format_message(self.failure_message)) + consume = self.consume_on_fail + + if consume and consumables: + # consume the inputs + for obj in consumables: + obj.delete() + + return craft_result + + +# access functions + + +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 + `settings.CRAFT_RECIPE_MODULES` to be added to a list of one or more + python-paths to modules holding Recipe-classes. + + Args: + crafter (Object): The one doing the crafting. + recipe_name (str): This should match the `CraftRecipe.name` to use. + *inputs: Suitable ingredients (Objects) to use in the crafting. + raise_exception (bool, optional): If crafting failed for whatever + reason, raise `CraftingError`. + **kwargs: Optional kwargs to pass into the recipe (will passed into recipe.craft). + + Returns: + list: Crafted objects, if any. + + Raises: + CraftingError: If `raise_exception` is True and crafting failed to produce an output. + KeyError: If `recipe_name` failed to find a matching recipe class. + + Notes: + If no recipe_module is given, will look for a list `settings.CRAFT_RECIPE_MODULES` and + lastly fall back to the example module `"evennia.contrib."` + + """ + # delayed loading/caching of recipes + _load_recipes() + + RecipeClass = _RECIPE_CLASSES.get(recipe_name, None) + if not RecipeClass: + raise KeyError("No recipe in settings.CRAFT_RECIPE_MODULES " + f"has a name matching {recipe_name}") + recipe = RecipeClass(crafter, *inputs, **kwargs) + return recipe.craft(raise_exception=raise_exception) diff --git a/evennia/contrib/crafting/example_recipes.py b/evennia/contrib/crafting/example_recipes.py new file mode 100644 index 0000000000..1f724cb4c7 --- /dev/null +++ b/evennia/contrib/crafting/example_recipes.py @@ -0,0 +1,263 @@ +""" +Example recipes for the crafting system - how to make a sword. + +See the _SwordSmithingBaseRecipe for an example of extendng the recipe with a +mocked 'skill' system (just random chance in our case). The skill system used +is game-specific but likely to be needed for most 'real' crafting systems. + +Note that 'tools' are references to the tools used - they don't need to be in +the inventory of the crafter. So when 'blast furnace' is given below, it is a +reference to a blast furnace used, not suggesting the crafter is carrying it +around with them. + +:: + + Sword crafting tree + + # base materials (consumables) + + iron ore, ash, sand, coal, oak wood, water, fur + + # base tools (marked with [T] for clarity and assumed to already exist) + + blast furnace[T], furnace[T], crucible[T], anvil[T], + hammer[T], knife[T], cauldron[T] + + # recipes for making a sword + + pig iron = iron ore + 2xcoal + blast furnace[T] + crucible_steel = pig iron + ash + sand + 2xcoal + crucible[T] + sword blade = crucible steel + hammer[T] + anvil[T] + furnace[T] + sword pommel = crucible steel + hammer[T] + anvil[T] + furnace[T] + sword guard = crucible steel + hammer[T] + anvil[T] + furnace[T] + + rawhide = fur + knife[T] + oak bark = oak wood + knife[T] + leather = rawhide + oak bark + water + cauldron[T] + + sword handle = oak wood + knife[T] + + sword = sword blade + sword guard + sword pommel + + sword handle + leather + knife[T] + hammer[T] + furnace[T] + + +""" + +from random import random +from .crafting import CraftingRecipe + + +class PigIronRecipe(CraftingRecipe): + """ + Pig iron is a high-carbon result of melting iron in a blast furnace. + + """ + name = "pig iron" + tool_tags = ["blast furnace"] + consumable_tags = ["iron ore", "coal", "coal"] + output_prototypes = [ + {"key": "Pig Iron ingot", + "desc": "An ingot of crude pig iron.", + "tags": [("pig iron", "crafting_material")]} + ] + + +class CrucibleSteelRecipe(CraftingRecipe): + """ + Mixing pig iron with impurities like ash and sand and melting it in a + crucible produces a medieval level of steel (like damascus steel). + + """ + name = "crucible steel" + tool_tags = ["crucible"] + consumable_tags = ["pig iron", "ash", "sand", "coal", "coal"] + output_prototypes = [ + {"key": "Crucible steel ingot", + "desc": "An ingot of multi-colored crucible steel.", + "tags": [("crucible steel", "crafting_material")]} + ] + + +class _SwordSmithingBaseRecipe(CraftingRecipe): + """ + A parent for all metallurgy sword-creation recipes. Those have a chance to + failure but since steel is not lost in the process you can always try + again. + + """ + + success_message = "Your smithing work bears fruit and you craft {outputs}!" + 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): + """ + 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 + against a skill found on `self.crafter`). In this case you can always + start over since steel is not lost but can be re-smelted again for + another try. + + Args: + validated_inputs (list): all consumables/tools being used. + **kwargs: any extra kwargs passed during crafting. + + Returns: + any: The result of the craft, or None if a failure. + + Notes: + Depending on if we return a crafting result from this + method or not, `success_message` or `failure_message` + will be echoed to the crafter. + + (for more control we could also message directly and raise + crafting.CraftingError to abort craft process on failure). + + """ + if random.random() < 0.8: + # 80% chance of success. This will spawn the sword and show + # success-message. + return super().do_craft() + else: + # fail and show failed message + return None + + +class SwordBladeRecipe(_SwordSmithingBaseRecipe): + """ + A [sword]blade requires hammering the steel out into shape using heat and + force. This also includes the tang, which is the base for the hilt (the + part of the sword you hold on to). + + """ + name = "sword blade" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + {"key": "Sword blade", + "desc": "A long blade that may one day become a sword.", + "tags": [("sword blade", "crafting_material")]} + ] + +class SwordPommelRecipe(_SwordSmithingBaseRecipe): + """ + The pommel is the 'button' or 'ball' etc the end of the sword hilt, holding + it together. + + """ + name = "sword pommel" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + {"key": "Sword pommel", + "desc": "The pommel for a future sword.", + "tags": [("sword pommel", "crafting_material")]} + ] + + +class SwordGuardRecipe(_SwordSmithingBaseRecipe): + """ + The guard stops the hand from accidentally sliding off the hilt onto the + sword's blade and also protects the hand when parrying. + + """ + name = "sword pommel" + tool_tags = ["hammer", "anvil", "furnace"] + consumable_tags = ["crucible steel"] + output_prototypes = [ + {"key": "Sword guard", + "desc": "The cross-guard for a future sword.", + "tags": [("sword guard", "crafting_material")]} + ] + + +class RawhideRecipe(CraftingRecipe): + """ + Rawhide is animal skin cleaned and stripped of hair. + + """ + name = "rawhide" + tool_tags = ["knife"] + consumable_tags = ["fur"] + output_prototypes = [ + {"key": "Rawhide", + "desc": "Animal skin, cleaned and with hair removed.", + "tags": [("rawhide", "crafting_material")]} + ] + + +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. + + """ + name = "oak bark" + tool_tags = ["knife"] + consumable_tags = ["oak wood"] + output_prototypes = [ + {"key": "Oak bark", + "desc": "Bark of oak, stripped from the core wood.", + "tags": [("oak bark", "crafting_material")]} + ] + + +class LeatherRecipe(CraftingRecipe): + """ + Leather is produced by tanning rawhide in a process traditionally involving + the chemical Tannin. Here we abbreviate this process a bit. Maybe a + 'tanning rack' tool should be required too ... + + """ + name = "leather" + tool_tags = ["cauldron"] + consumable_tags = ["rawhide", "oak bark", "water"] + output_prototypes = [ + {"key": "Piece of Leather", + "desc": "A piece of leather.", + "tags": [("leather", "crafting_material")]} + ] + + +class SwordHandleRecipe(CraftingRecipe): + """ + The handle is the part of the hilt between the guard and the pommel where + you hold the sword. It consists of wooden pieces around the steel tang. It + is wrapped in leather, but that will be added at the end. + + """ + name = "sword handle" + tool_tags = ["knife"] + consumable_tags = ["oak wood"] + output_prototypes = [ + {"key": "Sword handle", + "desc": "Two pieces of wood to be be fitted onto a sword's tang as its handle.", + "tags": [("sword handle", "crafting_material")]} + ] + + +class SwordRecipe(_SwordSmithingBaseRecipe): + """ + A finished sword consists of a Blade ending in a non-sharp part called the + Tang. The cross Guard is put over the tang against the edge of the blade. + The Handle is put over the tang to give something easier to hold. The + Pommel locks everything in place. The handle is wrapped in leather + strips for better grip. + + This covers only a single 'sword' type. + + """ + name = "sword" + tool_tags = ["hammer", "furnace", "knife"] + consumable_tags = ["sword blade", "sword guard", "sword pommel", "sword handle", + "leather"] + output_prototypes = [ + {"key": "Sword", + "desc": "A bladed weapon.", + # setting the tag as well - who knows if one can make something from this too! + "tags": [("sword", "crafting_material")]} + # obviously there would be other properties of a 'sword' added here + # too, depending on how combat works in the your game! + ] + # this requires more precision + exact_consumable_order = True diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 2e5c8c789d..2c67593c29 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -344,7 +344,7 @@ def columnize(string, columns=2, spacing=4, align="l", width=None): return "\n".join(rows) -def iter_to_string(initer, endsep="and", addquote=False): +def iter_to_str(initer, endsep="and", addquote=False): """ This pretty-formats an iterable list as string output, adding an optional alternative separator to the second to last entry. If `addquote` @@ -391,8 +391,9 @@ def iter_to_string(initer, endsep="and", addquote=False): return ", ".join(str(v) for v in initer[:-1]) + "%s %s" % (endsep, initer[-1]) -# legacy alias -list_to_string = iter_to_string +# legacy aliases +list_to_string = iter_to_str +iter_to_string = iter_to_str def wildcard_to_regexp(instring):