crafting.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174
  1. """
  2. Crafting - Griatch 2020
  3. This is a general crafting engine. The basic functionality of crafting is to
  4. combine any number of of items or tools in a 'recipe' to produce a new result.
  5. item + item + item + tool + tool -> recipe -> new result
  6. This is useful not only for traditional crafting but the engine is flexible
  7. enough to also be useful for puzzles or similar.
  8. ## Installation
  9. - Add the `CmdCraft` Command from this module to your default cmdset. This
  10. allows for crafting from in-game using a simple syntax.
  11. - Create a new module and add it to a new list in your settings file
  12. (`server/conf/settings.py`) named `CRAFT_RECIPES_MODULES`, such as
  13. `CRAFT_RECIPE_MODULES = ["world.recipes_weapons"]`.
  14. - In the new module(s), create one or more classes, each a child of
  15. `CraftingRecipe` from this module. Each such class must have a unique `.name`
  16. property. It also defines what inputs are required and what is created using
  17. this recipe.
  18. - Objects to use for crafting should (by default) be tagged with tags using the
  19. tag-category `crafting_material` or `crafting_tool`. The name of the object
  20. doesn't matter, only its tag.
  21. ## Crafting in game
  22. The default `craft` command handles all crafting needs.
  23. ::
  24. > craft spiked club from club, nails
  25. Here, `spiked club` specifies the recipe while `club` and `nails` are objects
  26. the crafter must have in their inventory. These will be consumed during
  27. crafting (by default only if crafting was successful).
  28. A recipe can also require *tools* (like the `hammer` above). These must be
  29. either in inventory *or* be in the current location. Tools are *not* consumed
  30. during the crafting process.
  31. ::
  32. > craft wooden doll from wood with knife
  33. ## Crafting in code
  34. In code, you should use the helper function `craft` from this module. This
  35. specifies the name of the recipe to use and expects all suitable
  36. ingredients/tools as arguments (consumables and tools should be added together,
  37. tools will be identified before consumables).
  38. ```python
  39. from evennia.contrib.crafting import crafting
  40. spiked_club = crafting.craft(crafter, "spiked club", club, nails)
  41. ```
  42. The result is always a list with zero or more objects. A fail leads to an empty
  43. list. The crafter should already have been notified of any error in this case
  44. (this should be handle by the recipe itself).
  45. ## Recipes
  46. A *recipe* is a class that works like an input/output blackbox: you initialize
  47. it with consumables (and/or tools) if they match the recipe, a new
  48. result is spit out. Consumables are consumed in the process while tools are not.
  49. This module contains a base class for making new ingredient types
  50. (`CraftingRecipeBase`) and an implementation of the most common form of
  51. crafting (`CraftingRecipe`) using objects and prototypes.
  52. Recipes are put in one or more modules added as a list to the
  53. `CRAFT_RECIPE_MODULES` setting, for example:
  54. ```python
  55. CRAFT_RECIPE_MODULES = ['world.recipes_weapons', 'world.recipes_potions']
  56. ```
  57. Below is an example of a crafting recipe and how `craft` calls it under the
  58. hood. See the `CraftingRecipe` class for details of which properties and
  59. methods are available to override - the craft behavior can be modified
  60. substantially this way.
  61. ```python
  62. from evennia.contrib.crafting.crafting import CraftingRecipe
  63. class PigIronRecipe(CraftingRecipe):
  64. # Pig iron is a high-carbon result of melting iron in a blast furnace.
  65. name = "pig iron" # this is what crafting.craft and CmdCraft uses
  66. tool_tags = ["blast furnace"]
  67. consumable_tags = ["iron ore", "coal", "coal"]
  68. output_prototypes = [
  69. {"key": "Pig Iron ingot",
  70. "desc": "An ingot of crude pig iron.",
  71. "tags": [("pig iron", "crafting_material")]}
  72. ]
  73. # for testing, conveniently spawn all we need based on the tags on the class
  74. tools, consumables = PigIronRecipe.seed()
  75. recipe = PigIronRecipe(caller, *(tools + consumables))
  76. result = recipe.craft()
  77. ```
  78. If the above class was added to a module in `CRAFT_RECIPE_MODULES`, it could be
  79. called using its `.name` property, as "pig iron".
  80. The [example_recipies](api:evennia.contrib.crafting.example_recipes) module has
  81. a full example of the components for creating a sword from base components.
  82. ----
  83. """
  84. from copy import copy
  85. from evennia import logger
  86. from evennia.utils.utils import callables_from_module, inherits_from, make_iter, iter_to_string
  87. from evennia.commands.cmdset import CmdSet
  88. from evennia.commands.command import Command
  89. from evennia.prototypes.spawner import spawn
  90. from evennia.utils.create import create_object, create_script
  91. from typeclasses.scripts import CmdActionScript
  92. from utils.utils import toggle_effect, indefinite_article, has_effect
  93. _RECIPE_CLASSES = {}
  94. def _load_recipes():
  95. """
  96. Delayed loading of recipe classes. This parses
  97. `settings.CRAFT_RECIPE_MODULES`.
  98. """
  99. from django.conf import settings
  100. global _RECIPE_CLASSES
  101. if not _RECIPE_CLASSES:
  102. paths = ["evennia.contrib.crafting.example_recipes"]
  103. if hasattr(settings, "CRAFT_RECIPE_MODULES"):
  104. paths += make_iter(settings.CRAFT_RECIPE_MODULES)
  105. for path in paths:
  106. for cls in callables_from_module(path).values():
  107. if inherits_from(cls, CraftingRecipeBase):
  108. _RECIPE_CLASSES[cls.name] = cls
  109. class CraftingError(RuntimeError):
  110. """
  111. Crafting error.
  112. """
  113. class CraftingValidationError(CraftingError):
  114. """
  115. Error if crafting validation failed.
  116. """
  117. class CraftingRecipeBase:
  118. """
  119. The recipe handles all aspects of performing a 'craft' operation. This is
  120. the base of the crafting system, intended to be replace if you want to
  121. adapt it for very different functionality - see the `CraftingRecipe` child
  122. class for an implementation of the most common type of crafting using
  123. objects.
  124. Example of usage:
  125. ::
  126. recipe = CraftRecipe(crafter, obj1, obj2, obj3)
  127. result = recipe.craft()
  128. Note that the most common crafting operation is that the inputs are
  129. consumed - so in that case the recipe cannot be used a second time (doing so
  130. will raise a `CraftingError`)
  131. Process:
  132. 1. `.craft(**kwargs)` - this starts the process on the initialized recipe. The kwargs
  133. are optional but will be passed into all of the following hooks.
  134. 2. `.pre_craft(**kwargs)` - this normally validates inputs and stores them in
  135. `.validated_inputs.`. Raises `CraftingValidationError` otherwise.
  136. 4. `.do_craft(**kwargs)` - should return the crafted item(s) or the empty list. Any
  137. crafting errors should be immediately reported to user.
  138. 5. `.post_craft(crafted_result, **kwargs)`- always called, even if `pre_craft`
  139. raised a `CraftingError` or `CraftingValidationError`.
  140. Should return `crafted_result` (modified or not).
  141. """
  142. name = "recipe base"
  143. # if set, allow running `.craft` more than once on the same instance.
  144. # don't set this unless crafting inputs are *not* consumed by the crafting
  145. # process (otherwise subsequent calls will fail).
  146. allow_reuse = False
  147. def __init__(self, crafter, *inputs, **kwargs):
  148. """
  149. Initialize the recipe.
  150. Args:
  151. crafter (Object): The one doing the crafting.
  152. *inputs (any): The ingredients of the recipe to use.
  153. **kwargs (any): Any other parameters that are relevant for
  154. this recipe.
  155. """
  156. self.crafter = crafter
  157. self.inputs = inputs
  158. self.craft_kwargs = kwargs
  159. self.allow_craft = True
  160. self.validated_inputs = []
  161. def msg(self, message, **kwargs):
  162. """
  163. Send message to crafter. This is a central point to override if wanting
  164. to change crafting return style in some way.
  165. Args:
  166. message(str): The message to send.
  167. **kwargs: Any optional properties relevant to this send.
  168. """
  169. self.crafter.msg(message, oob=({"type": "crafting"}))
  170. def pre_craft(self, **kwargs):
  171. """
  172. Hook to override.
  173. This is called just before crafting operation and is normally
  174. responsible for validating the inputs, storing data on
  175. `self.validated_inputs`.
  176. Args:
  177. **kwargs: Optional extra flags passed during initialization or
  178. `.craft(**kwargs)`.
  179. Raises:
  180. CraftingValidationError: If validation fails.
  181. """
  182. if self.allow_craft:
  183. self.validated_inputs = self.inputs[:]
  184. else:
  185. raise CraftingValidationError
  186. def do_craft(self, **kwargs):
  187. """
  188. Hook to override.
  189. This performs the actual crafting. At this point the inputs are
  190. expected to have been verified already. If needed, the validated
  191. inputs are available on this recipe instance.
  192. Args:
  193. **kwargs: Any extra flags passed at initialization.
  194. Returns:
  195. any: The result of crafting.
  196. """
  197. return None
  198. def post_craft(self, crafting_result, **kwargs):
  199. """
  200. Hook to override.
  201. This is called just after crafting has finished. A common use of this
  202. method is to delete the inputs.
  203. Args:
  204. crafting_result (any): The outcome of crafting, as returned by `do_craft`.
  205. **kwargs: Any extra flags passed at initialization.
  206. Returns:
  207. any: The final crafting result.
  208. """
  209. return crafting_result
  210. def craft(self, raise_exception=False, **kwargs):
  211. """
  212. Main crafting call method. Call this to produce a result and make
  213. sure all hooks run correctly.
  214. Args:
  215. raise_exception (bool): If crafting would return `None`, raise
  216. exception instead.
  217. **kwargs (any): Any other parameters that is relevant
  218. for this particular craft operation. This will temporarily
  219. override same-named kwargs given at the creation of this recipe
  220. and be passed into all of the crafting hooks.
  221. Returns:
  222. any: The result of the craft, or `None` if crafting failed.
  223. Raises:
  224. CraftingValidationError: If recipe validation failed and
  225. `raise_exception` is True.
  226. CraftingError: On If trying to rerun a no-rerun recipe, or if crafting
  227. would return `None` and raise_exception` is set.
  228. """
  229. craft_result = None
  230. if self.allow_craft:
  231. # override/extend craft_kwargs from initialization.
  232. craft_kwargs = copy(self.craft_kwargs)
  233. craft_kwargs.update(kwargs)
  234. try:
  235. try:
  236. # this assigns to self.validated_inputs
  237. self.pre_craft(**craft_kwargs)
  238. except (CraftingError, CraftingValidationError):
  239. if raise_exception:
  240. raise
  241. else:
  242. craft_result = self.do_craft(**craft_kwargs)
  243. finally:
  244. craft_result = self.post_craft(craft_result, **craft_kwargs)
  245. except (CraftingError, CraftingValidationError):
  246. if raise_exception:
  247. raise
  248. # possibly turn off re-use depending on class setting
  249. self.allow_craft = self.allow_reuse
  250. elif not self.allow_reuse:
  251. raise CraftingError("Cannot re-run crafting without re-initializing recipe first.")
  252. if craft_result is None and raise_exception:
  253. raise CraftingError(f"Crafting of {self.name} failed.")
  254. return craft_result
  255. class CraftingRecipe(CraftingRecipeBase):
  256. """
  257. The CraftRecipe implements the most common form of crafting: Combining (and
  258. consuming) inputs to produce a new result. This type of recipe only works
  259. with typeclassed entities as inputs and outputs, since it's based on Tags
  260. and Prototypes.
  261. There are two types of crafting ingredients: 'tools' and 'consumables'. The
  262. difference between them is that the former is not consumed in the crafting
  263. process. So if you need a hammer and anvil to craft a sword, they are
  264. 'tools' whereas the materials of the sword are 'consumables'.
  265. Examples:
  266. ::
  267. class FlourRecipe(CraftRecipe):
  268. name = "flour"
  269. tool_tags = ['windmill']
  270. consumable_tags = ["wheat"]
  271. output_prototypes = [
  272. {"key": "Bag of flour",
  273. "typeclass": "typeclasses.food.Flour",
  274. "desc": "A small bag of flour."
  275. "tags": [("flour", "crafting_material"),
  276. }
  277. class BreadRecipe(CraftRecipe):
  278. name = "bread"
  279. tool_tags = ["roller", "owen"]
  280. consumable_tags = ["flour", "egg", "egg", "salt", "water", "yeast"]
  281. output_prototypes = [
  282. {"key": "bread",
  283. "desc": "A tasty bread."
  284. }
  285. ## Properties on the class level:
  286. - `name` (str): The name of this recipe. This should be globally unique.
  287. - 'crafting_time' (int): The time needed for crafting.
  288. ### tools
  289. - `tool_tag_category` (str): What tag-category tools must use. Default is
  290. 'crafting_tool'.
  291. - `tool_tags` (list): Object-tags to use for tooling. If more than one instace
  292. of a tool is needed, add multiple entries here.
  293. - `tool_names` (list): Human-readable names for tools. These are used for informative
  294. messages/errors. If not given, the tags will be used. If given, this list should
  295. match the length of `tool_tags`.:
  296. - `exact_tools` (bool, default True): Must have exactly the right tools, any extra
  297. leads to failure.
  298. - `exact_tool_order` (bool, default False): Tools must be added in exactly the
  299. right order for crafting to pass.
  300. ### consumables
  301. - `consumable_tag_category` (str): What tag-category consumables must use.
  302. Default is 'crafting_material'.
  303. - `consumable_tags` (list): Tags for objects that will be consumed as part of
  304. running the recipe.
  305. - `consumable_names` (list): Human-readable names for consumables. Same as for tools.
  306. - `exact_consumables` (bool, default True): Normally, adding more consumables
  307. than needed leads to a a crafting error. If this is False, the craft will
  308. still succeed (only the needed ingredients will be consumed).
  309. - `exact_consumable_order` (bool, default False): Normally, the order in which
  310. ingredients are added does not matter. With this set, trying to add consumables in
  311. another order than given will lead to failing crafting.
  312. - `consume_on_fail` (bool, default False): Normally, consumables remain if
  313. crafting fails. With this flag, a failed crafting will still consume
  314. consumables. Note that this will also consume any 'extra' consumables
  315. added not part of the recipe!
  316. ### outputs (result of crafting)
  317. - `output_prototypes` (list): One or more prototypes (`prototype_keys` or
  318. full dicts) describing how to create the result(s) of this recipe.
  319. - `output_names` (list): Human-readable names for (prospective) prototypes.
  320. This is used in error messages. If not given, this is extracted from the
  321. prototypes' `key` if possible.
  322. ### custom error messages
  323. custom messages all have custom formatting markers. Many are empty strings
  324. when not applicable.
  325. ::
  326. {missing}: Comma-separated list of tool/consumable missing for missing/out of order errors.
  327. {excess}: Comma-separated list of tool/consumable added in excess of recipe
  328. {inputs}: Comma-separated list of any inputs (tools + consumables) involved in error.
  329. {tools}: Comma-sepatated list of tools involved in error.
  330. {consumables}: Comma-separated list of consumables involved in error.
  331. {outputs}: Comma-separated list of (expected) outputs
  332. {t0}..{tN-1}: Individual tools, same order as `.tool_names`.
  333. {c0}..{cN-1}: Individual consumables, same order as `.consumable_names`.
  334. {o0}..{oN-1}: Individual outputs, same order as `.output_names`.
  335. - `error_tool_missing_message`: "Could not craft {outputs} without {missing}."
  336. - `error_tool_order_message`:
  337. "Could not craft {outputs} since {missing} was added in the wrong order."
  338. - `error_tool_excess_message`: "Could not craft {outputs} (extra {excess})."
  339. - `error_consumable_missing_message`: "Could not craft {outputs} without {missing}."
  340. - `error_consumable_order_message`:
  341. "Could not craft {outputs} since {missing} was added in the wrong order."
  342. - `error_consumable_excess_message`: "Could not craft {outputs} (excess {excess})."
  343. - `success_message`: "You successfuly craft {outputs}!"
  344. - `failure_message`: "" (this is handled by the other error messages by default)
  345. ## Hooks
  346. 1. Crafting starts by calling `.craft(**kwargs)` on the parent class. The
  347. `**kwargs` are optional, extends any `**kwargs` passed to the class
  348. constructor and will be passed into all the following hooks.
  349. 3. `.pre_craft(**kwargs)` should handle validation of inputs. Results should
  350. be stored in `validated_consumables/tools` respectively. Raises `CraftingValidationError`
  351. otherwise.
  352. 4. `.do_craft(**kwargs)` will not be called if validation failed. Should return
  353. a list of the things crafted.
  354. 5. `.post_craft(crafting_result, **kwargs)` is always called, also if validation
  355. failed (`crafting_result` will then be falsy). It does any cleanup. By default
  356. this deletes consumables.
  357. Use `.msg` to conveniently send messages to the crafter. Raise
  358. `evennia.contrib.crafting.crafting.CraftingError` exception to abort
  359. crafting at any time in the sequence. If raising with a text, this will be
  360. shown to the crafter automatically
  361. """
  362. name = "crafting recipe"
  363. # this define the overall category all material tags must have
  364. consumable_tag_category = "crafting_material"
  365. # tag category for tool objects
  366. tool_tag_category = "crafting_tool"
  367. # the tools needed to perform this crafting. Tools are never consumed (if they were,
  368. # they'd need to be a consumable). If more than one instance of a tool is needed,
  369. # there should be multiple entries in this list.
  370. tool_tags = []
  371. # human-readable names for the tools. This will be used for informative messages
  372. # or when usage fails. If empty
  373. tool_names = []
  374. # if we must have exactly the right tools, no more
  375. exact_tools = True
  376. # if the order of the tools matters
  377. exact_tool_order = False
  378. # error to show if missing tools
  379. error_tool_missing_message = "Could not craft {outputs} without {missing}."
  380. # error to show if tool-order matters and it was wrong. Missing is the first
  381. # tool out of order
  382. error_tool_order_message = (
  383. "Could not craft {outputs} since {missing} was added in the wrong order."
  384. )
  385. # if .exact_tools is set and there are more than needed
  386. error_tool_excess_message = (
  387. "Could not craft {outputs} without the exact tools (extra {excess})."
  388. )
  389. # a list of tag-keys (of the `tag_category`). If more than one of each type
  390. # is needed, there should be multiple same-named entries in this list.
  391. consumable_tags = []
  392. # these are human-readable names for the items to use. This is used for informative
  393. # messages or when usage fails. If empty, the tag-names will be used. If given, this
  394. # must have the same length as `consumable_tags`.
  395. consumable_names = []
  396. # if True, consume valid inputs also if crafting failed (returned None)
  397. consume_on_fail = False
  398. # if True, having any wrong input result in failing the crafting. If False,
  399. # extra components beyond the recipe are ignored.
  400. exact_consumables = True
  401. # if True, the exact order in which inputs are provided matters and must match
  402. # the order of `consumable_tags`. If False, order doesn't matter.
  403. exact_consumable_order = False
  404. # error to show if missing consumables
  405. error_consumable_missing_message = "Could not craft {outputs} without {missing}."
  406. # error to show if consumable order matters and it was wrong. Missing is the first
  407. # consumable out of order
  408. error_consumable_order_message = (
  409. "Could not craft {outputs} since {missing} was added in the wrong order."
  410. )
  411. # if .exact_consumables is set and there are more than needed
  412. error_consumable_excess_message = (
  413. "Could not craft {outputs} without the exact ingredients (extra {excess})."
  414. )
  415. # this is a list of one or more prototypes (prototype_keys to existing
  416. # prototypes or full prototype-dicts) to use to build the result. All of
  417. # these will be returned (as a list) if crafting succeeded.
  418. output_prototypes = []
  419. # human-readable name(s) for the (expected) result of this crafting. This will usually only
  420. # be used for error messages (to report what would have been). If not given, the
  421. # prototype's key or typeclass will be used. If given, this must have the same length
  422. # as `output_prototypes`.
  423. output_names = []
  424. # general craft-failure msg to show after other error-messages.
  425. failure_message = ""
  426. # show after a successful craft
  427. success_message = "You craft {outputs}."
  428. # recipe crafting time
  429. crafting_time = 1
  430. def __init__(self, crafter, *inputs, **kwargs):
  431. """
  432. Args:
  433. crafter (Object): The one doing the crafting.
  434. *inputs (Object): The ingredients (+tools) of the recipe to use. The
  435. The recipe will itself figure out (from tags) which is a tool and
  436. which is a consumable.
  437. **kwargs (any): Any other parameters that are relevant for
  438. this recipe. These will be passed into the crafting hooks.
  439. Notes:
  440. Internally, this class stores validated data in
  441. `.validated_consumables` and `.validated_tools` respectively. The
  442. `.validated_inputs` property (from parent) holds a list of everything
  443. types in the order inserted to the class constructor.
  444. """
  445. super().__init__(crafter, *inputs, **kwargs)
  446. self.validated_consumables = []
  447. self.validated_tools = []
  448. # validate class properties
  449. if self.consumable_names:
  450. assert len(self.consumable_names) == len(self.consumable_tags), (
  451. f"Crafting {self.__class__}.consumable_names list must "
  452. "have the same length as .consumable_tags."
  453. )
  454. else:
  455. self.consumable_names = self.consumable_tags
  456. if self.tool_names:
  457. assert len(self.tool_names) == len(self.tool_tags), (
  458. f"Crafting {self.__class__}.tool_names list must "
  459. "have the same length as .tool_tags."
  460. )
  461. else:
  462. self.tool_names = self.tool_tags
  463. if self.output_names:
  464. assert len(self.consumable_names) == len(self.consumable_tags), (
  465. f"Crafting {self.__class__}.output_names list must "
  466. "have the same length as .output_prototypes."
  467. )
  468. else:
  469. self.output_names = [
  470. prot.get("key", prot.get("typeclass", "unnamed"))
  471. if isinstance(prot, dict)
  472. else str(prot)
  473. for prot in self.output_prototypes
  474. ]
  475. assert isinstance(
  476. self.output_prototypes, (list, tuple)
  477. ), "Crafting {self.__class__}.output_prototypes must be a list or tuple."
  478. # don't allow reuse if we have consumables. If only tools we can reuse
  479. # over and over since nothing changes.
  480. self.allow_reuse = not bool(self.consumable_tags)
  481. def _format_message(self, message, **kwargs):
  482. missing = iter_to_string(kwargs.get("missing", ""))
  483. excess = iter_to_string(kwargs.get("excess", ""))
  484. involved_tools = iter_to_string(kwargs.get("tools", ""))
  485. involved_cons = iter_to_string(kwargs.get("consumables", ""))
  486. # build template context
  487. mapping = {"missing": missing, "excess": excess}
  488. mapping.update(
  489. {
  490. f"i{ind}": self.consumable_names[ind]
  491. for ind, name in enumerate(self.consumable_names or self.consumable_tags)
  492. }
  493. )
  494. mapping.update(
  495. {f"o{ind}": self.output_names[ind] for ind, name in enumerate(self.output_names)}
  496. )
  497. mapping["tools"] = involved_tools
  498. mapping["consumables"] = involved_cons
  499. mapping["inputs"] = iter_to_string(self.consumable_names)
  500. mapping["outputs"] = iter_to_string(self.output_names)
  501. # populate template and return
  502. return message.format(**mapping)
  503. @classmethod
  504. def seed(cls, tool_kwargs=None, consumable_kwargs=None):
  505. """
  506. This is a helper class-method for easy testing and application of this
  507. recipe. When called, it will create simple dummy ingredients with names
  508. and tags needed by this recipe.
  509. Args:
  510. consumable_kwargs (dict, optional): This will be passed as
  511. `**consumable_kwargs` into the `create_object` call for each consumable.
  512. If not given, matching `consumable_name` or `consumable_tag`
  513. will be used for key.
  514. tool_kwargs (dict, optional): Will be passed as `**tool_kwargs` into the `create_object`
  515. call for each tool. If not given, the matching
  516. `tool_name` or `tool_tag` will be used for key.
  517. Returns:
  518. tuple: A tuple `(tools, consumables)` with newly created dummy
  519. objects matching the recipe ingredient list.
  520. Example:
  521. ::
  522. tools, consumables = SwordRecipe.seed()
  523. recipe = SwordRecipe(caller, *(tools + consumables))
  524. result = recipe.craft()
  525. Notes:
  526. If `key` is given in `consumable/tool_kwargs` then _every_ created item
  527. of each type will have the same key.
  528. """
  529. if not tool_kwargs:
  530. tool_kwargs = {}
  531. if not consumable_kwargs:
  532. consumable_kwargs = {}
  533. tool_key = tool_kwargs.pop("key", None)
  534. cons_key = consumable_kwargs.pop("key", None)
  535. tool_tags = tool_kwargs.pop("tags", [])
  536. cons_tags = consumable_kwargs.pop("tags", [])
  537. tools = []
  538. for itag, tag in enumerate(cls.tool_tags):
  539. tools.append(
  540. create_object(
  541. key=tool_key or (cls.tool_names[itag] if cls.tool_names else tag.capitalize()),
  542. tags=[(tag, cls.tool_tag_category), *tool_tags],
  543. **tool_kwargs,
  544. )
  545. )
  546. consumables = []
  547. for itag, tag in enumerate(cls.consumable_tags):
  548. consumables.append(
  549. create_object(
  550. key=cons_key
  551. or (cls.consumable_names[itag] if cls.consumable_names else tag.capitalize()),
  552. tags=[(tag, cls.consumable_tag_category), *cons_tags],
  553. **consumable_kwargs,
  554. )
  555. )
  556. return tools, consumables
  557. def pre_craft(self, **kwargs):
  558. """
  559. Do pre-craft checks, including input validation.
  560. Check so the given inputs are what is needed. This operates on
  561. `self.inputs` which is set to the inputs added to the class
  562. constructor. Validated data is stored as lists on `.validated_tools`
  563. and `.validated_consumables` respectively.
  564. Args:
  565. **kwargs: Any optional extra kwargs passed during initialization of
  566. the recipe class.
  567. Raises:
  568. CraftingValidationError: If validation fails. At this point the crafter
  569. is expected to have been informed of the problem already.
  570. """
  571. def _check_completeness(
  572. tagmap,
  573. taglist,
  574. namelist,
  575. exact_match,
  576. exact_order,
  577. error_missing_message,
  578. error_order_message,
  579. error_excess_message,
  580. ):
  581. """Compare tagmap (inputs) to taglist (required)"""
  582. valids = []
  583. for itag, tagkey in enumerate(taglist):
  584. found_obj = None
  585. for obj, objtags in tagmap.items():
  586. if tagkey in objtags:
  587. found_obj = obj
  588. break
  589. if exact_order:
  590. # if we get here order is wrong
  591. err = self._format_message(
  592. error_order_message, missing=obj.get_display_name(looker=self.crafter)
  593. )
  594. self.msg(err)
  595. raise CraftingValidationError(err)
  596. # since we pop from the mapping, it gets ever shorter
  597. match = tagmap.pop(found_obj, None)
  598. if match:
  599. valids.append(found_obj)
  600. elif exact_match:
  601. err = self._format_message(
  602. error_missing_message,
  603. missing=namelist[itag] if namelist else tagkey.capitalize(),
  604. )
  605. self.msg(err)
  606. raise CraftingValidationError(err)
  607. if exact_match and tagmap:
  608. # something is left in tagmap, that means it was never popped and
  609. # thus this is not an exact match
  610. err = self._format_message(
  611. error_excess_message,
  612. excess=[obj.get_display_name(looker=self.crafter) for obj in tagmap],
  613. )
  614. self.msg(err)
  615. raise CraftingValidationError(err)
  616. return valids
  617. # get tools and consumables from self.inputs
  618. tool_map = {
  619. obj: obj.tags.get(category=self.tool_tag_category, return_list=True)
  620. for obj in self.inputs
  621. if obj
  622. and hasattr(obj, "tags")
  623. and inherits_from(obj, "evennia.objects.models.ObjectDB")
  624. }
  625. tool_map = {obj: tags for obj, tags in tool_map.items() if tags}
  626. consumable_map = {
  627. obj: obj.tags.get(category=self.consumable_tag_category, return_list=True)
  628. for obj in self.inputs
  629. if obj
  630. and hasattr(obj, "tags")
  631. and obj not in tool_map
  632. and inherits_from(obj, "evennia.objects.models.ObjectDB")
  633. }
  634. consumable_map = {obj: tags for obj, tags in consumable_map.items() if tags}
  635. # we set these so they are available for error management at all times,
  636. # they will be updated with the actual values at the end
  637. self.validated_tools = [obj for obj in tool_map]
  638. self.validated_consumables = [obj for obj in consumable_map]
  639. tools = _check_completeness(
  640. tool_map,
  641. self.tool_tags,
  642. self.tool_names,
  643. self.exact_tools,
  644. self.exact_tool_order,
  645. self.error_tool_missing_message,
  646. self.error_tool_order_message,
  647. self.error_tool_excess_message,
  648. )
  649. consumables = _check_completeness(
  650. consumable_map,
  651. self.consumable_tags,
  652. self.consumable_names,
  653. self.exact_consumables,
  654. self.exact_consumable_order,
  655. self.error_consumable_missing_message,
  656. self.error_consumable_order_message,
  657. self.error_consumable_excess_message,
  658. )
  659. # regardless of flags, the tools/consumable lists much contain exactly
  660. # all the recipe needs now.
  661. if len(tools) != len(self.tool_tags):
  662. raise CraftingValidationError(
  663. f"Tools {tools}'s tags do not match expected tags {self.tool_tags}"
  664. )
  665. if len(consumables) != len(self.consumable_tags):
  666. raise CraftingValidationError(
  667. f"Consumables {consumables}'s tags do not match "
  668. f"expected tags {self.consumable_tags}"
  669. )
  670. self.validated_tools = tools
  671. self.validated_consumables = consumables
  672. def do_craft(self, **kwargs):
  673. """
  674. Hook to override. This will not be called if validation in `pre_craft`
  675. fails.
  676. This performs the actual crafting. At this point the inputs are
  677. expected to have been verified already.
  678. Returns:
  679. list: A list of spawned objects created from the inputs, or None
  680. on a failure.
  681. Notes:
  682. This method should use `self.msg` to inform the user about the
  683. specific reason of failure immediately.
  684. We may want to analyze the tools in some way here to affect the
  685. crafting process.
  686. """
  687. return spawn(*self.output_prototypes)
  688. def post_craft(self, craft_result, **kwargs):
  689. """
  690. Hook to override.
  691. This is called just after crafting has finished. A common use of
  692. this method is to delete the inputs.
  693. Args:
  694. craft_result (list): The crafted result, provided by `self.do_craft`.
  695. **kwargs (any): Passed from `self.craft`.
  696. Returns:
  697. list: The return(s) of the craft, possibly modified in this method.
  698. Notes:
  699. This is _always_ called, also if validation in `pre_craft` fails
  700. (`craft_result` will then be `None`).
  701. """
  702. if craft_result:
  703. self.msg(self._format_message(self.success_message))
  704. elif self.failure_message:
  705. self.msg(self._format_message(self.failure_message))
  706. if craft_result or self.consume_on_fail:
  707. # consume the inputs
  708. for obj in self.validated_consumables:
  709. obj.delete()
  710. return craft_result
  711. # access function
  712. def craft(crafter, recipe_name, *inputs, raise_exception=False, **kwargs):
  713. """
  714. Access function. Craft a given recipe from a source recipe module. A
  715. recipe module is a Python module containing recipe classes. Note that this
  716. requires `settings.CRAFT_RECIPE_MODULES` to be added to a list of one or
  717. more python-paths to modules holding Recipe-classes.
  718. Args:
  719. crafter (Object): The one doing the crafting.
  720. recipe_name (str): The `CraftRecipe.name` to use. This uses fuzzy-matching
  721. if the result is unique.
  722. *inputs: Suitable ingredients and/or tools (Objects) to use in the crafting.
  723. raise_exception (bool, optional): If crafting failed for whatever
  724. reason, raise `CraftingError`. The user will still be informed by the
  725. recipe.
  726. **kwargs: Optional kwargs to pass into the recipe (will passed into
  727. recipe.craft).
  728. Returns:
  729. list: Crafted objects, if any.
  730. Raises:
  731. CraftingError: If `raise_exception` is True and crafting failed to
  732. produce an output. KeyError: If `recipe_name` failed to find a
  733. matching recipe class (or the hit was not precise enough.)
  734. Notes:
  735. If no recipe_module is given, will look for a list `settings.CRAFT_RECIPE_MODULES` and
  736. lastly fall back to the example module `"evennia.contrib."`
  737. """
  738. # delayed loading/caching of recipes
  739. _load_recipes()
  740. RecipeClass = search_recipe(crafter, recipe_name)
  741. if not RecipeClass:
  742. raise KeyError(
  743. f"No recipe in settings.CRAFT_RECIPE_MODULES has a name matching {recipe_name}"
  744. )
  745. recipe = RecipeClass(crafter, *inputs, **kwargs)
  746. return recipe.craft(raise_exception=raise_exception)
  747. def can_craft(crafter, recipe_name, *inputs, **kwargs):
  748. """
  749. Access function.Check if crafter can craft a given recipe from a source recipe module.
  750. Args:
  751. crafter (Object): The one doing the crafting.
  752. recipe_name (str): The `CraftRecipe.name` to use. This uses fuzzy-matching
  753. if the result is unique.
  754. *inputs: Suitable ingredients and/or tools (Objects) to use in the crafting.
  755. raise_exception (bool, optional): If crafting failed for whatever
  756. reason, raise `CraftingError`. The user will still be informed by the
  757. recipe.
  758. **kwargs: Optional kwargs to pass into the recipe (will passed into
  759. recipe.craft).
  760. Returns:
  761. list: Error messages, if any.
  762. Raises:
  763. CraftingError: If `raise_exception` is True and crafting failed to
  764. produce an output. KeyError: If `recipe_name` failed to find a
  765. matching recipe class (or the hit was not precise enough.)
  766. Notes:
  767. If no recipe_module is given, will look for a list `settings.CRAFT_RECIPE_MODULES` and
  768. lastly fall back to the example module `"evennia.contrib."`
  769. """
  770. # delayed loading/caching of recipes
  771. _load_recipes()
  772. RecipeClass = search_recipe(crafter, recipe_name)
  773. if not RecipeClass:
  774. raise KeyError(
  775. f"No recipe in settings.CRAFT_RECIPE_MODULES has a name matching {recipe_name}"
  776. )
  777. recipe = RecipeClass(crafter, *inputs, **kwargs)
  778. if recipe.allow_craft:
  779. # override/extend craft_kwargs from initialization.
  780. craft_kwargs = copy(recipe.craft_kwargs)
  781. craft_kwargs.update(kwargs)
  782. try:
  783. recipe.pre_craft(**craft_kwargs)
  784. except (CraftingError, CraftingValidationError):
  785. logger.log_err(CraftingValidationError.args)
  786. return False
  787. else:
  788. return True
  789. return False
  790. def search_recipe(crafter, recipe_name):
  791. # delayed loading/caching of recipes
  792. _load_recipes()
  793. recipe_class = _RECIPE_CLASSES.get(recipe_name, None)
  794. if not recipe_class:
  795. # try a startswith fuzzy match
  796. matches = [key for key in _RECIPE_CLASSES if key.startswith(recipe_name)]
  797. if not matches:
  798. # try in-match
  799. matches = [key for key in _RECIPE_CLASSES if recipe_name in key]
  800. if len(matches) == 1:
  801. recipe_class = _RECIPE_CLASSES.get(matches[0], None)
  802. return recipe_class
  803. # craft command/cmdset
  804. class CraftingCmdSet(CmdSet):
  805. """
  806. Store crafting command.
  807. """
  808. key = "Crafting cmdset"
  809. def at_cmdset_creation(self):
  810. self.add(CmdCraft())
  811. class CmdCraft(Command):
  812. """
  813. Craft an item using ingredients and tools
  814. Usage:
  815. craft <recipe> [from <ingredient>,...] [using <tool>, ...]
  816. Examples:
  817. craft snowball from snow
  818. craft puppet from piece of wood using knife
  819. craft bread from flour, butter, water, yeast using owen, bowl, roller
  820. craft fireball using wand, spellbook
  821. Notes:
  822. Ingredients must be in the crafter's inventory. Tools can also be
  823. things in the current location, like a furnace, windmill or anvil.
  824. """
  825. key = "craft"
  826. locks = "cmd:all()"
  827. help_category = "General"
  828. arg_regex = r"\s|$"
  829. def parse(self):
  830. """
  831. Handle parsing of:
  832. ::
  833. <recipe> [FROM <ingredients>] [USING <tools>]
  834. Examples:
  835. ::
  836. craft snowball from snow
  837. craft puppet from piece of wood using knife
  838. craft bread from flour, butter, water, yeast using owen, bowl, roller
  839. craft fireball using wand, spellbook
  840. """
  841. self.args = args = self.args.strip().lower()
  842. recipe, ingredients, tools = "", "", ""
  843. if "from" in args:
  844. recipe, *rest = args.split(" from ", 1)
  845. rest = rest[0] if rest else ""
  846. ingredients, *tools = rest.split(" using ", 1)
  847. elif "using" in args:
  848. recipe, *tools = args.split(" using ", 1)
  849. tools = tools[0] if tools else ""
  850. self.recipe = recipe.strip()
  851. self.ingredients = [ingr.strip() for ingr in ingredients.split(",")]
  852. self.tools = [tool.strip() for tool in tools.split(",")]
  853. def func(self):
  854. """
  855. Perform crafting.
  856. Will check the `craft` locktype. If a consumable/ingredient does not pass
  857. this check, we will check for the 'crafting_consumable_err_msg'
  858. Attribute, otherwise will use a default. If failing on a tool, will use
  859. the `crafting_tool_err_msg` if available.
  860. """
  861. caller = self.caller
  862. if not self.args or not self.recipe:
  863. self.caller.msg("Usage: craft <recipe> from <ingredient>, ... [using <tool>,...]")
  864. return
  865. if has_effect(caller, "is_busy"):
  866. caller.msg("You are already busy {}.".format(caller.current_action.busy_msg()))
  867. return
  868. ingredients = []
  869. for ingr_key in self.ingredients:
  870. if not ingr_key:
  871. continue
  872. obj = caller.search(ingr_key, location=self.caller)
  873. # since ingredients are consumed we need extra check so we don't
  874. # try to include characters or accounts etc.
  875. if not obj:
  876. return
  877. if (
  878. not inherits_from(obj, "evennia.objects.models.ObjectDB")
  879. or obj.sessions.all()
  880. or not obj.access(caller, "craft", default=True)
  881. ):
  882. # We don't allow to include puppeted objects nor those with the
  883. # 'negative' permission 'nocraft'.
  884. caller.msg(
  885. obj.attributes.get(
  886. "crafting_consumable_err_msg",
  887. default=f"{obj.get_display_name(looker=caller)} can't be used for this.",
  888. )
  889. )
  890. return
  891. ingredients.append(obj)
  892. tools = []
  893. for tool_key in self.tools:
  894. if not tool_key:
  895. continue
  896. # tools are not consumed, can also exist in the current room
  897. obj = caller.search(tool_key)
  898. if not obj:
  899. return None
  900. if not obj.access(caller, "craft", default=True):
  901. caller.msg(
  902. obj.attributes.get(
  903. "crafting_tool_err_msg",
  904. default=f"{obj.get_display_name(looker=caller)} can't be used for this.",
  905. )
  906. )
  907. return
  908. tools.append(obj)
  909. recipe_cls = search_recipe(caller, self.recipe)
  910. if not recipe_cls:
  911. caller.msg("You don't know how to craft {} {}.".format(indefinite_article(self.recipe), self.recipe))
  912. return
  913. tools_and_ingredients = tools + ingredients
  914. if not can_craft(caller, recipe_cls.name, *tools_and_ingredients):
  915. return
  916. toggle_effect(caller, "is_busy")
  917. caller.msg("You start crafting {} {}.".format(indefinite_article(recipe_cls.name), recipe_cls.name))
  918. action_script = create_script("utils.crafting.CmdCraftComplete", obj=caller, interval=recipe_cls.crafting_time, attributes=[("recipe", recipe_cls.name), ("tools_and_ingredients", tools_and_ingredients)])
  919. caller.db.current_action = action_script
  920. class CmdCraftComplete(CmdActionScript):
  921. def at_script_creation(self):
  922. super().at_script_creation()
  923. self.key = "cmd_craft_complete"
  924. self.desc = ""
  925. self.db.recipe = ""
  926. self.db.tools_and_ingredients = ""
  927. def at_repeat(self):
  928. caller = self.obj
  929. if has_effect(caller, "is_busy"):
  930. toggle_effect(caller, "is_busy")
  931. # perform craft and make sure result is in inventory
  932. # (the recipe handles all returns to caller)
  933. result = craft(caller, self.db.recipe, *self.db.tools_and_ingredients)
  934. if result:
  935. for obj in result:
  936. if inherits_from(obj, "typeclasses.objects.Feature"):
  937. obj.location = caller.location
  938. else:
  939. obj.location = caller
  940. def busy_msg(self):
  941. return "crafting {} {}".format(indefinite_article(self.db.recipe), self.db.recipe)