Start refactor contrib folder

This commit is contained in:
Griatch 2021-12-18 11:32:34 +01:00
parent 7f0d314e7f
commit f5f75bd04d
107 changed files with 34 additions and 2 deletions

View file

@ -0,0 +1,102 @@
# Crafting system
Contrib - Griatch 2020
This implements a full crafting system. The principle is that of a 'recipe':
ingredient1 + ingredient2 + ... + tool1 + tool2 + ... + craft_recipe -> objectA, objectB, ...
Here, 'ingredients' are consumed by the crafting process, whereas 'tools' are
necessary for the process by will not be destroyed by it.
An example would be to use the tools 'bowl' and 'oven' to use the ingredients
'flour', 'salt', 'yeast' and 'water' to create 'bread' using the 'bread recipe'.
A recipe does not have to use tools, like 'snow' + 'snowball-recipe' becomes
'snowball'. Conversely one could also imagine using tools without consumables,
like using 'spell book' and 'wand' to produce 'fireball' by having the recipe
check some magic skill on the character.
The system is generic enough to be used also for adventure-like puzzles, like
combining 'stick', 'string' and 'hook' to get a 'makeshift fishing rod' that
you can use with 'storm drain' (treated as a tool) to get 'key' ...
## Intallation and Usage
Import the `CmdCraft` command from evennia/contrib/crafting/crafting.py and
add it to your Character cmdset. Reload and the `craft` command will be
available to you:
craft <recipe> [from <ingredient>,...] [using <tool>, ...]
For example
craft toy car from plank, wooden wheels, nails using saw, hammer
To use crafting you need recipes. Add a new variable to `mygame/server/conf/settings.py`:
CRAFT_RECIPE_MODULES = ['world.recipes']
All top-level classes in these modules (whose name does not start with `_`)
will be parsed by Evennia as recipes to make available to the crafting system.
Using the above example, create `mygame/world/recipes.py` and add your recipies
in there:
```python
from evennia.contrib.crafting.crafting import CraftingRecipe, CraftingValidationError
class RecipeBread(CraftingRecipe):
"""
Bread is good for making sandwitches!
"""
name = "bread" # used to identify this recipe in 'craft' command
tool_tags = ["bowl", "oven"]
consumable_tags = ["flour", "salt", "yeast", "water"]
output_prototypes = [
{"key": "Loaf of Bread",
"aliases": ["bread"],
"desc": "A nice load of bread.",
"typeclass": "typeclasses.objects.Food", # assuming this exists
"tags": [("bread", "crafting_material")] # this makes it usable in other recipes ...
}
]
def pre_craft(self, **kwargs):
# validates inputs etc. Raise `CraftingValidationError` if fails
def craft(self, **kwargs):
# performs the craft - but it can still fail (check skills etc here)
def craft(self, result, **kwargs):
# any post-crafting effects. Always called, even if crafting failed (be
# result would be None then)
```
## Technical
The Recipe is a class that specifies the consumables, tools and output along
with various methods (that you can override) to do the the validation of inputs
and perform the crafting itself.
By default the input is a list of object-tags (using the "crafting_material"
and "crafting_tool" tag-categories respectively). Providing a set of objects
matching these tags are required for the crafting to be done. The use of tags
means that multiple different objects could all work for the same recipe, as
long as they have the right tag. This can be very useful for allowing players
to experiment and explore alternative ways to create things!
The output is given by a set of prototype-dicts. If the input is correct and
other checks are passed (such as crafting skill, for example), these prototypes
will be used to generate the new object(s) being crafted.
Each recipe is a stand-alone entity which allows for very advanced
customization for every recipe - for example one could have a recipe that
checks other properties of the inputs (like quality, color etc) and have that
affect the result. Your recipes could also (and likely would) tie into your
game's skill system to determine the success or outcome of the crafting.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,530 @@
"""
How to make a sword - example crafting tree for the crafting system.
See the `SwordSmithingBaseRecipe` in this module for an example of extendng the
recipe with a mocked 'skill' system (just random chance in our case). The skill
system used is game-specific but likely to be needed for most 'real' crafting
systems.
Note that 'tools' are references to the tools used - they don't need to be in
the inventory of the crafter. So when 'blast furnace' is given below, it is a
reference to a blast furnace used, not suggesting the crafter is carrying it
around with them.
## Sword crafting tree
::
# base materials (consumables)
iron ore, ash, sand, coal, oak wood, water, fur
# base tools (marked with [T] for clarity and assumed to already exist)
blast furnace[T], furnace[T], crucible[T], anvil[T],
hammer[T], knife[T], cauldron[T]
# recipes for making a sword
pig iron = iron ore + 2xcoal + blast furnace[T]
crucible_steel = pig iron + ash + sand + 2xcoal + crucible[T]
sword blade = crucible steel + hammer[T] + anvil[T] + furnace[T]
sword pommel = crucible steel + hammer[T] + anvil[T] + furnace[T]
sword guard = crucible steel + hammer[T] + anvil[T] + furnace[T]
rawhide = fur + knife[T]
oak bark + cleaned oak wood = oak wood + knife[T]
leather = rawhide + oak bark + water + cauldron[T]
sword handle = cleaned oak wood + knife[T]
sword = sword blade + sword guard + sword pommel
+ sword handle + leather + knife[T] + hammer[T] + furnace[T]
## Recipes used for spell casting
This is a simple example modifying the base Recipe to use as a way
to describe magical spells instead. It combines tools with
a skill (an attribute on the caster) in order to produce a magical effect.
The example `CmdCast` command can be added to the CharacterCmdset in
`mygame/commands/default_cmdsets` to test it out. The 'effects' are
just mocked for the example.
::
# base tools (assumed to already exist)
spellbook[T], wand[T]
# skill (stored as Attribute on caster)
firemagic skill level10+
# recipe for fireball
fireball = spellbook[T] + wand[T] + [firemagic skill lvl10+]
----
"""
from random import random, randint
from evennia.commands.command import Command, InterruptCommand
from .crafting import craft, CraftingRecipe, CraftingValidationError
#------------------------------------------------------------
# Sword recipe
#------------------------------------------------------------
class PigIronRecipe(CraftingRecipe):
"""
Pig iron is a high-carbon result of melting iron in a blast furnace.
"""
name = "pig iron"
tool_tags = ["blast furnace"]
consumable_tags = ["iron ore", "coal", "coal"]
output_prototypes = [
{
"key": "Pig Iron ingot",
"desc": "An ingot of crude pig iron.",
"tags": [("pig iron", "crafting_material")],
}
]
class CrucibleSteelRecipe(CraftingRecipe):
"""
Mixing pig iron with impurities like ash and sand and melting it in a
crucible produces a medieval level of steel (like damascus steel).
"""
name = "crucible steel"
tool_tags = ["crucible"]
consumable_tags = ["pig iron", "ash", "sand", "coal", "coal"]
output_prototypes = [
{
"key": "Crucible steel ingot",
"desc": "An ingot of multi-colored crucible steel.",
"tags": [("crucible steel", "crafting_material")],
}
]
class _SwordSmithingBaseRecipe(CraftingRecipe):
"""
A parent for all metallurgy sword-creation recipes. Those have a chance to
failure but since steel is not lost in the process you can always try
again.
"""
success_message = "Your smithing work bears fruit and you craft {outputs}!"
failed_message = (
"You work and work but you are not happy with the result. You need to start over."
)
def 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
against a skill found on `self.crafter`). In this case you can always
start over since steel is not lost but can be re-smelted again for
another try.
Args:
validated_inputs (list): all consumables/tools being used.
**kwargs: any extra kwargs passed during crafting.
Returns:
any: The result of the craft, or None if a failure.
Notes:
Depending on if we return a crafting result from this
method or not, `success_message` or `failure_message`
will be echoed to the crafter.
(for more control we could also message directly and raise
crafting.CraftingError to abort craft process on failure).
"""
if random.random() < 0.8:
# 80% chance of success. This will spawn the sword and show
# success-message.
return super().craft(**kwargs)
else:
# fail and show failed message
return None
class SwordBladeRecipe(_SwordSmithingBaseRecipe):
"""
A [sword]blade requires hammering the steel out into shape using heat and
force. This also includes the tang, which is the base for the hilt (the
part of the sword you hold on to).
"""
name = "sword blade"
tool_tags = ["hammer", "anvil", "furnace"]
consumable_tags = ["crucible steel"]
output_prototypes = [
{
"key": "Sword blade",
"desc": "A long blade that may one day become a sword.",
"tags": [("sword blade", "crafting_material")],
}
]
class SwordPommelRecipe(_SwordSmithingBaseRecipe):
"""
The pommel is the 'button' or 'ball' etc the end of the sword hilt, holding
it together.
"""
name = "sword pommel"
tool_tags = ["hammer", "anvil", "furnace"]
consumable_tags = ["crucible steel"]
output_prototypes = [
{
"key": "Sword pommel",
"desc": "The pommel for a future sword.",
"tags": [("sword pommel", "crafting_material")],
}
]
class SwordGuardRecipe(_SwordSmithingBaseRecipe):
"""
The guard stops the hand from accidentally sliding off the hilt onto the
sword's blade and also protects the hand when parrying.
"""
name = "sword guard"
tool_tags = ["hammer", "anvil", "furnace"]
consumable_tags = ["crucible steel"]
output_prototypes = [
{
"key": "Sword guard",
"desc": "The cross-guard for a future sword.",
"tags": [("sword guard", "crafting_material")],
}
]
class RawhideRecipe(CraftingRecipe):
"""
Rawhide is animal skin cleaned and stripped of hair.
"""
name = "rawhide"
tool_tags = ["knife"]
consumable_tags = ["fur"]
output_prototypes = [
{
"key": "Rawhide",
"desc": "Animal skin, cleaned and with hair removed.",
"tags": [("rawhide", "crafting_material")],
}
]
class OakBarkRecipe(CraftingRecipe):
"""
The actual thing needed for tanning leather is Tannin, but we skip
the step of refining tannin from the bark and use the bark as-is.
This produces two outputs - the bark and the cleaned wood.
"""
name = "oak bark"
tool_tags = ["knife"]
consumable_tags = ["oak wood"]
output_prototypes = [
{
"key": "Oak bark",
"desc": "Bark of oak, stripped from the core wood.",
"tags": [("oak bark", "crafting_material")],
},
{
"key": "Oak Wood (cleaned)",
"desc": "Oakwood core, stripped of bark.",
"tags": [("cleaned oak wood", "crafting_material")],
},
]
class LeatherRecipe(CraftingRecipe):
"""
Leather is produced by tanning rawhide in a process traditionally involving
the chemical Tannin. Here we abbreviate this process a bit. Maybe a
'tanning rack' tool should be required too ...
"""
name = "leather"
tool_tags = ["cauldron"]
consumable_tags = ["rawhide", "oak bark", "water"]
output_prototypes = [
{
"key": "Piece of Leather",
"desc": "A piece of leather.",
"tags": [("leather", "crafting_material")],
}
]
class SwordHandleRecipe(CraftingRecipe):
"""
The handle is the part of the hilt between the guard and the pommel where
you hold the sword. It consists of wooden pieces around the steel tang. It
is wrapped in leather, but that will be added at the end.
"""
name = "sword handle"
tool_tags = ["knife"]
consumable_tags = ["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.",
"tags": [("sword handle", "crafting_material")],
}
]
class SwordRecipe(_SwordSmithingBaseRecipe):
"""
A finished sword consists of a Blade ending in a non-sharp part called the
Tang. The cross Guard is put over the tang against the edge of the blade.
The Handle is put over the tang to give something easier to hold. The
Pommel locks everything in place. The handle is wrapped in leather
strips for better grip.
This covers only a single 'sword' type.
"""
name = "sword"
tool_tags = ["hammer", "furnace", "knife"]
consumable_tags = ["sword blade", "sword guard", "sword pommel", "sword handle", "leather"]
output_prototypes = [
{
"key": "Sword",
"desc": "A bladed weapon.",
# setting the tag as well - who knows if one can make something from this too!
"tags": [("sword", "crafting_material")],
}
# obviously there would be other properties of a 'sword' added here
# too, depending on how combat works in the your game!
]
# this requires more precision
exact_consumable_order = True
#------------------------------------------------------------
# Recipes for spell casting
#------------------------------------------------------------
class _MagicRecipe(CraftingRecipe):
"""
A base 'recipe' to represent magical spells.
We *could* treat this just like the sword above - by combining the wand and spellbook to make a
fireball object that the user can then throw with another command. For this example we instead
generate 'magical effects' as strings+values that we would then supposedly inject into a
combat system or other resolution system.
We also assume that the crafter has skills set on itself as plain Attributes.
"""
name = ""
# all spells require a spellbook and a wand (so there!)
tool_tags = ["spellbook", "wand"]
error_tool_missing_message = "Cannot cast spells without {missing}."
success_message = "You successfully cast the spell!"
# custom properties
skill_requirement = [] # this should be on the form [(skillname, min_level)]
skill_roll = "" # skill to roll for success
desired_effects = [] # on the form [(effect, value), ...]
failure_effects = [] # ''
error_too_low_skill_level = "Your skill {skill_name} is too low to cast {spell}."
error_no_skill_roll = "You must have the skill {skill_name} to cast the spell {spell}."
def pre_craft(self, **kwargs):
"""
This is where we do input validation. We want to do the
normal validation of the tools, but also check for a skill
on the crafter. This must set the result on `self.validated_inputs`.
We also set the crafter's relevant skill value on `self.skill_roll_value`.
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.
"""
# this will check so the spellbook and wand are at hand.
super().pre_craft(**kwargs)
# at this point we have the items available, let's also check for the skill. We
# assume the crafter has the skill available as an Attribute
# on itself.
crafter = self.crafter
for skill_name, min_value in self.skill_requirements:
skill_value = crafter.attributes.get(skill_name)
if skill_value is None or skill_value < min_value:
self.msg(self.error_too_low_skill_level.format(skill_name=skill_name,
spell=self.name))
raise CraftingValidationError
# get the value of the skill to roll
self.skill_roll_value = self.crafter.attributes.get(self.skill_roll)
if self.skill_roll_value is None:
self.msg(self.error_no_skill_roll.format(skill_name=self.skill_roll,
spell=self.name))
raise CraftingValidationError
def do_craft(self, **kwargs):
"""
'Craft' the magical effect. When we get to this point we already know we have all the
prequisite for creating the effect. In this example we will store the effect on the crafter;
maybe this enhances the crafter or makes a new attack available to them in combat.
An alternative to this would of course be to spawn an actual object for the effect, like
creating a potion or an actual fireball-object to throw (this depends on how your combat
works).
"""
# we do a simple skill check here.
if randint(1, 18) <= self.skill_roll_value:
# a success!
return True, self.desired_effects
else:
# a failure!
return False, self.failure_effects
def post_craft(self, craft_result, **kwargs):
"""
Always called at the end of crafting, regardless of successful or not.
Since we get a custom craft result (True/False, effects) we need to
wrap the original post_craft to output the error messages for us
correctly.
"""
success = False
if craft_result:
success, _ = craft_result
# default post_craft just checks if craft_result is truthy or not.
# we don't care about its return value since we already have craft_result.
super().post_craft(success, **kwargs)
return craft_result
class FireballRecipe(_MagicRecipe):
"""
A Fireball is a magical effect that can be thrown at a target to cause damage.
Note that the magic-effects are just examples, an actual rule system would
need to be created to understand what they mean when used.
"""
name = "fireball"
skill_requirements = [('firemagic', 10)] # skill 'firemagic' lvl 10 or higher
skill_roll = "firemagic"
success_message = "A ball of flame appears!"
desired_effects = [('target_fire_damage', 25), ('ranged_attack', -2), ('mana_cost', 12)]
failure_effects = [('self_fire_damage', 5), ('mana_cost', 5)]
class HealingRecipe(_MagicRecipe):
"""
Healing magic will restore a certain amount of health to the target over time.
Note that the magic-effects are just examples, an actual rule system would
need to be created to understand what they mean.
"""
name = "heal"
skill_requirements = [('bodymagic', 5), ("empathy", 10)]
skill_roll = "bodymagic"
success_message = "You successfully extend your healing aura."
desired_effects = [('healing', 15), ('mana_cost', 5)]
failure_effects = []
class CmdCast(Command):
"""
Cast a magical spell.
Usage:
cast <spell> <target>
"""
key = 'cast'
def parse(self):
"""
Simple parser, assuming spellname doesn't have spaces.
Stores result in self.target and self.spellname.
"""
args = self.args.strip().lower()
target = None
if ' ' in args:
self.spellname, *target = args.split(' ', 1)
else:
self.spellname = args
if not self.spellname:
self.caller.msg("You must specify a spell name.")
raise InterruptCommand
if target:
self.target = self.caller.search(target[0].strip())
if not self.target:
raise InterruptCommand
else:
self.target = self.caller
def func(self):
# all items carried by the caller could work
possible_tools = self.caller.contents
try:
# if this completes without an exception, the caster will have
# a new magic_effect set on themselves, ready to use or apply in some way.
success, effects = craft(self.caller, self.spellname, *possible_tools,
raise_exception=True)
except CraftingValidationError:
return
except KeyError:
self.caller.msg(f"You don't know of a spell called '{self.spellname}'")
return
# Applying the magical effect to target would happen below.
# self.caller.db.active_spells[self.spellname] holds all the effects
# of this particular prepared spell. For a fireball you could perform
# an attack roll here and apply damage if you hit. For healing you would heal the target
# (which could be yourself) by a number of health points given by the recipe.
effect_txt = ", ".join(f"{eff[0]}({eff[1]})" for eff in effects)
success_txt = "|gsucceeded|n" if success else "|rfailed|n"
self.caller.msg(f"Casting the spell {self.spellname} on {self.target} {success_txt}, "
f"causing the following effects: {effect_txt}.")

View file

@ -0,0 +1,690 @@
"""
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,
"fireball": example_recipes.FireballRecipe,
"heal": example_recipes.HealingRecipe,
"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__success(self):
"""Test seed helper classmethod"""
# needed for other dbs to pass seed
homeroom = create_object(key="HomeRoom", nohome=True)
# call classmethod directly
with override_settings(DEFAULT_HOME=f"#{homeroom.id}"):
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"),
)