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