1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174 |
- """
- Crafting - Griatch 2020
- This is a general crafting engine. The basic functionality of crafting is to
- combine any number of of items or tools in a 'recipe' to produce a new result.
- item + item + item + tool + tool -> recipe -> new result
- This is useful not only for traditional crafting but the engine is flexible
- enough to also be useful for puzzles or similar.
- ## Installation
- - Add the `CmdCraft` Command from this module to your default cmdset. This
- allows for crafting from in-game using a simple syntax.
- - Create a new module and add it to a new list in your settings file
- (`server/conf/settings.py`) named `CRAFT_RECIPES_MODULES`, such as
- `CRAFT_RECIPE_MODULES = ["world.recipes_weapons"]`.
- - In the new module(s), 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` or `crafting_tool`. The name of the object
- doesn't matter, only its tag.
- ## Crafting in game
- The default `craft` command handles all crafting needs.
- ::
- > 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* (like the `hammer` above). These must be
- either in inventory *or* be in the current location. Tools are *not* consumed
- during the crafting process.
- ::
- > craft wooden doll from wood with knife
- ## Crafting in code
- 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).
- ```python
- from evennia.contrib.crafting import crafting
- spiked_club = crafting.craft(crafter, "spiked club", club, nails)
- ```
- The result is always a list with zero or more objects. A fail leads to an empty
- list. The crafter should already have been notified of any error in this case
- (this should be handle by the recipe itself).
- ## Recipes
- A *recipe* is a class that works like an input/output blackbox: you initialize
- it with consumables (and/or tools) 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_RECIPE_MODULES` setting, for example:
- ```python
- CRAFT_RECIPE_MODULES = ['world.recipes_weapons', 'world.recipes_potions']
- ```
- Below is an example of a crafting recipe and how `craft` calls it under the
- hood. See the `CraftingRecipe` class for details of which properties and
- methods are available to override - the craft behavior can be modified
- substantially this way.
- ```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" # this is what crafting.craft and CmdCraft uses
- 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")]}
- ]
- # for testing, conveniently spawn all we need based on the tags on the class
- tools, consumables = PigIronRecipe.seed()
- recipe = PigIronRecipe(caller, *(tools + consumables))
- result = recipe.craft()
- ```
- If the above class was added to a module in `CRAFT_RECIPE_MODULES`, it could be
- called using its `.name` property, as "pig iron".
- The [example_recipies](api:evennia.contrib.crafting.example_recipes) module has
- a full example of the components for creating a sword from base components.
- ----
- """
- from copy import copy
- from evennia import logger
- from evennia.utils.utils import callables_from_module, inherits_from, make_iter, iter_to_string
- 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, create_script
- from typeclasses.scripts import CmdActionScript
- from utils.utils import toggle_effect, indefinite_article, has_effect
- _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:
- paths = ["evennia.contrib.crafting.example_recipes"]
- if hasattr(settings, "CRAFT_RECIPE_MODULES"):
- paths += make_iter(settings.CRAFT_RECIPE_MODULES)
- for path in paths:
- for cls in callables_from_module(path).values():
- if inherits_from(cls, CraftingRecipeBase):
- _RECIPE_CLASSES[cls.name] = cls
- class CraftingError(RuntimeError):
- """
- Crafting error.
- """
- class CraftingValidationError(CraftingError):
- """
- Error if crafting validation failed.
- """
- class CraftingRecipeBase:
- """
- 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:
- ::
- 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`)
- 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"
- # 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 = inputs
- self.craft_kwargs = kwargs
- self.allow_craft = True
- self.validated_inputs = []
- 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, oob=({"type": "crafting"}))
- def pre_craft(self, **kwargs):
- """
- Hook to override.
- This is called just before crafting operation and is normally
- responsible for validating the inputs, storing data on
- `self.validated_inputs`.
- Args:
- **kwargs: Optional extra flags passed during initialization or
- `.craft(**kwargs)`.
- Raises:
- CraftingValidationError: If validation fails.
- """
- if self.allow_craft:
- self.validated_inputs = self.inputs[:]
- else:
- raise CraftingValidationError
- def do_craft(self, **kwargs):
- """
- Hook to override.
- This performs the actual crafting. At this point the inputs are
- expected to have been verified already. If needed, the validated
- inputs are available on this recipe instance.
- Args:
- **kwargs: Any extra flags passed at initialization.
- Returns:
- any: The result of crafting.
- """
- return None
- def post_craft(self, crafting_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:
- crafting_result (any): The outcome of crafting, as returned by `do_craft`.
- **kwargs: Any extra flags passed at initialization.
- Returns:
- any: The final crafting result.
- """
- return crafting_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:
- CraftingValidationError: If recipe validation failed and
- `raise_exception` is True.
- CraftingError: On If trying to rerun a no-rerun recipe, or if crafting
- would return `None` and raise_exception` is set.
- """
- craft_result = None
- if self.allow_craft:
- # override/extend craft_kwargs from initialization.
- craft_kwargs = copy(self.craft_kwargs)
- craft_kwargs.update(kwargs)
- try:
- 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 re-initializing recipe first.")
- if craft_result is None and raise_exception:
- raise CraftingError(f"Crafting of {self.name} failed.")
- return craft_result
- class CraftingRecipe(CraftingRecipeBase):
- """
- The CraftRecipe implements the most common form of crafting: Combining (and
- 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 FlourRecipe(CraftRecipe):
- name = "flour"
- tool_tags = ['windmill']
- consumable_tags = ["wheat"]
- output_prototypes = [
- {"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.
- - 'crafting_time' (int): The time needed for crafting.
- ### tools
- - `tool_tag_category` (str): What tag-category tools must use. Default is
- 'crafting_tool'.
- - `tool_tags` (list): Object-tags to use for tooling. If more than one instace
- of a tool is needed, add multiple entries here.
- - `tool_names` (list): Human-readable names for tools. These are used for informative
- 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
- right order for crafting to pass.
- ### 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.
- - `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
- consumables. Note that this will also consume any 'extra' consumables
- added not part of the recipe!
- ### 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. 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.
- {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_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_excess_message`: "Could not craft {outputs} (excess {excess})."
- - `success_message`: "You successfuly craft {outputs}!"
- - `failure_message`: "" (this is handled by the other error messages by default)
- ## Hooks
- 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
- 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."
- )
- # 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.
- 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."
- )
- # 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
- # 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 = []
- # general craft-failure msg to show after other error-messages.
- failure_message = ""
- # show after a successful craft
- success_message = "You craft {outputs}."
- # recipe crafting time
- crafting_time = 1
- def __init__(self, crafter, *inputs, **kwargs):
- """
- 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__(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 "
- "have the same length as .consumable_tags."
- )
- else:
- self.consumable_names = self.consumable_tags
- if self.tool_names:
- assert len(self.tool_names) == len(self.tool_tags), (
- f"Crafting {self.__class__}.tool_names list must "
- "have the same length as .tool_tags."
- )
- else:
- self.tool_names = self.tool_tags
- if self.output_names:
- assert len(self.consumable_names) == len(self.consumable_tags), (
- f"Crafting {self.__class__}.output_names list must "
- "have the same length as .output_prototypes."
- )
- else:
- self.output_names = [
- prot.get("key", prot.get("typeclass", "unnamed"))
- if isinstance(prot, dict)
- else str(prot)
- for prot in self.output_prototypes
- ]
- assert isinstance(
- self.output_prototypes, (list, tuple)
- ), "Crafting {self.__class__}.output_prototypes must be a list or tuple."
- # don't allow reuse if we have consumables. If only tools we can reuse
- # over and over since nothing changes.
- self.allow_reuse = not bool(self.consumable_tags)
- def _format_message(self, message, **kwargs):
- missing = iter_to_string(kwargs.get("missing", ""))
- excess = iter_to_string(kwargs.get("excess", ""))
- involved_tools = iter_to_string(kwargs.get("tools", ""))
- involved_cons = iter_to_string(kwargs.get("consumables", ""))
- # build template context
- 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)
- }
- )
- mapping.update(
- {f"o{ind}": self.output_names[ind] for ind, name in enumerate(self.output_names)}
- )
- mapping["tools"] = involved_tools
- mapping["consumables"] = involved_cons
- mapping["inputs"] = iter_to_string(self.consumable_names)
- mapping["outputs"] = iter_to_string(self.output_names)
- # populate template and return
- return message.format(**mapping)
- @classmethod
- def seed(cls, tool_kwargs=None, consumable_kwargs=None):
- """
- 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
- `**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 `**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.
- Returns:
- 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.
- """
- 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(cls.tool_tags):
- tools.append(
- create_object(
- 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(cls.consumable_tags):
- consumables.append(
- create_object(
- key=cons_key
- or (cls.consumable_names[itag] if cls.consumable_names else tag.capitalize()),
- tags=[(tag, cls.consumable_tag_category), *cons_tags],
- **consumable_kwargs,
- )
- )
- return tools, consumables
- def pre_craft(self, **kwargs):
- """
- 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,
- error_excess_message,
- ):
- """Compare tagmap (inputs) to taglist (required)"""
- valids = []
- for itag, tagkey in enumerate(taglist):
- found_obj = None
- for obj, objtags in tagmap.items():
- if tagkey in objtags:
- found_obj = obj
- break
- if exact_order:
- # if we get here order is wrong
- err = self._format_message(
- error_order_message, 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")
- 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
- 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,
- self.tool_tags,
- self.tool_names,
- self.exact_tools,
- self.exact_tool_order,
- self.error_tool_missing_message,
- self.error_tool_order_message,
- self.error_tool_excess_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,
- 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(
- 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}"
- )
- self.validated_tools = tools
- self.validated_consumables = consumables
- def do_craft(self, **kwargs):
- """
- 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.
- Returns:
- 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.
- """
- return spawn(*self.output_prototypes)
- 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 (list): The crafted result, provided by `self.do_craft`.
- **kwargs (any): Passed from `self.craft`.
- Returns:
- 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))
- elif self.failure_message:
- self.msg(self._format_message(self.failure_message))
- if craft_result or self.consume_on_fail:
- # consume the inputs
- for obj in self.validated_consumables:
- obj.delete()
- return craft_result
- # access function
- def craft(crafter, recipe_name, *inputs, raise_exception=False, **kwargs):
- """
- Access function. 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): The `CraftRecipe.name` to use. This uses fuzzy-matching
- if the result is unique.
- *inputs: Suitable ingredients and/or tools (Objects) to use in the crafting.
- 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).
- 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 (or the hit was not precise enough.)
- 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 = search_recipe(crafter, recipe_name)
- if not RecipeClass:
- raise KeyError(
- f"No recipe in settings.CRAFT_RECIPE_MODULES has a name matching {recipe_name}"
- )
- recipe = RecipeClass(crafter, *inputs, **kwargs)
- return recipe.craft(raise_exception=raise_exception)
- def can_craft(crafter, recipe_name, *inputs, **kwargs):
- """
- Access function.Check if crafter can craft a given recipe from a source recipe module.
- Args:
- crafter (Object): The one doing the crafting.
- recipe_name (str): The `CraftRecipe.name` to use. This uses fuzzy-matching
- if the result is unique.
- *inputs: Suitable ingredients and/or tools (Objects) to use in the crafting.
- 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).
- Returns:
- list: Error messages, 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 (or the hit was not precise enough.)
- 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 = search_recipe(crafter, recipe_name)
- if not RecipeClass:
- raise KeyError(
- f"No recipe in settings.CRAFT_RECIPE_MODULES has a name matching {recipe_name}"
- )
- recipe = RecipeClass(crafter, *inputs, **kwargs)
- if recipe.allow_craft:
- # override/extend craft_kwargs from initialization.
- craft_kwargs = copy(recipe.craft_kwargs)
- craft_kwargs.update(kwargs)
- try:
- recipe.pre_craft(**craft_kwargs)
- except (CraftingError, CraftingValidationError):
- logger.log_err(CraftingValidationError.args)
- return False
- else:
- return True
- return False
- def search_recipe(crafter, recipe_name):
- # delayed loading/caching of recipes
- _load_recipes()
- recipe_class = _RECIPE_CLASSES.get(recipe_name, None)
- if not recipe_class:
- # try a startswith fuzzy match
- matches = [key for key in _RECIPE_CLASSES if key.startswith(recipe_name)]
- if not matches:
- # try in-match
- matches = [key for key in _RECIPE_CLASSES if recipe_name in key]
- if len(matches) == 1:
- recipe_class = _RECIPE_CLASSES.get(matches[0], None)
- return recipe_class
- # 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 <recipe> [from <ingredient>,...] [using <tool>, ...]
- 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.
- """
- key = "craft"
- locks = "cmd:all()"
- help_category = "General"
- arg_regex = r"\s|$"
- def parse(self):
- """
- Handle parsing of:
- ::
- <recipe> [FROM <ingredients>] [USING <tools>]
- 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
- """
- 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 <recipe> from <ingredient>, ... [using <tool>,...]")
- return
- if has_effect(caller, "is_busy"):
- caller.msg("You are already busy {}.".format(caller.current_action.busy_msg()))
- 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)
- recipe_cls = search_recipe(caller, self.recipe)
- if not recipe_cls:
- caller.msg("You don't know how to craft {} {}.".format(indefinite_article(self.recipe), self.recipe))
- return
- tools_and_ingredients = tools + ingredients
- if not can_craft(caller, recipe_cls.name, *tools_and_ingredients):
- return
- toggle_effect(caller, "is_busy")
- caller.msg("You start crafting {} {}.".format(indefinite_article(recipe_cls.name), recipe_cls.name))
- action_script = create_script("utils.crafting.CmdCraftComplete", obj=caller, interval=recipe_cls.crafting_time, attributes=[("recipe", recipe_cls.name), ("tools_and_ingredients", tools_and_ingredients)])
- caller.db.current_action = action_script
- class CmdCraftComplete(CmdActionScript):
- def at_script_creation(self):
- super().at_script_creation()
- self.key = "cmd_craft_complete"
- self.desc = ""
- self.db.recipe = ""
- self.db.tools_and_ingredients = ""
- def at_repeat(self):
- caller = self.obj
- if has_effect(caller, "is_busy"):
- toggle_effect(caller, "is_busy")
- # perform craft and make sure result is in inventory
- # (the recipe handles all returns to caller)
- result = craft(caller, self.db.recipe, *self.db.tools_and_ingredients)
- if result:
- for obj in result:
- if inherits_from(obj, "typeclasses.objects.Feature"):
- obj.location = caller.location
- else:
- obj.location = caller
- def busy_msg(self):
- return "crafting {} {}".format(indefinite_article(self.db.recipe), self.db.recipe)
|