1207 lines
45 KiB
Python
1207 lines
45 KiB
Python
"""
|
|
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 import evtable
|
|
from evennia.utils.utils import callables_from_module, inherits_from, make_iter, iter_to_string, list_to_string
|
|
from evennia.commands.cmdset import CmdSet
|
|
from 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())
|
|
self.add(CmdRecipes())
|
|
|
|
|
|
class CmdRecipes(Command):
|
|
"""
|
|
List known recipes.
|
|
|
|
Usage:
|
|
recipes
|
|
"""
|
|
|
|
key = "recipes"
|
|
locks = "cmd:all()"
|
|
help_category = "General"
|
|
arg_regex = r"\s|$"
|
|
|
|
def func(self):
|
|
# delayed loading/caching of recipes
|
|
_load_recipes()
|
|
|
|
caller = self.caller
|
|
|
|
table = evtable.EvTable("|wName|n", "|wMaterials", "|wTools|n",
|
|
border_left_char="|y|||n", border_right_char="|y|||n",
|
|
border_top_char="|y-|n", border_bottom_char="|y-|n",
|
|
corner_char="|y+|n")
|
|
|
|
for recipe in _RECIPE_CLASSES.values():
|
|
if (recipe.name in caller.db.recipes) or caller.is_superuser:
|
|
table.add_row("|W{}".format(recipe.name), "|M{}".format(list_to_string(recipe.tool_tags)), "|G{}".format(list_to_string(recipe.consumable_tags)))
|
|
|
|
caller.msg(table)
|
|
|
|
|
|
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.db.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)
|