diff --git a/evennia/contrib/puzzles.py b/evennia/contrib/puzzles.py new file mode 100644 index 0000000000..56f157abe5 --- /dev/null +++ b/evennia/contrib/puzzles.py @@ -0,0 +1,789 @@ +""" +Puzzles System - Provides a typeclass and commands for +objects that can be combined (i.e. 'use'd) to produce +new objects. + +Evennia contribution - Henddher 2018 + +A Puzzle is a recipe of what objects (aka parts) must +be combined by a player so a new set of objects +(aka results) are automatically created. + +Consider this simple Puzzle: + + orange, mango, yogurt, blender = fruit smoothie + +As a Builder: + + @create/drop orange + @create/drop mango + @create/drop yogurt + @create/drop blender + @create/drop fruit smoothie + + @puzzle smoothie, orange, mango, yogurt, blender = fruit smoothie + ... + Puzzle smoothie(#1234) created successfuly. + + @destroy/force orange, mango, yogurt, blender, fruit smoothie + + @armpuzzle #1234 + Part orange is spawned at ... + Part mango is spawned at ... + .... + Puzzle smoothie(#1234) has been armed successfully + +As Player: + + use orange, mango, yogurt, blender + ... + Genius, you blended all fruits to create a fruit smoothie! + +Details: + +Puzzles are created from existing objects. The given +objects are introspected to create prototypes for the +puzzle parts and results. These prototypes become the +puzzle recipe. (See PuzzleRecipe and @puzzle +command). Once the recipe is created, all parts and result +can be disposed (i.e. destroyed). + +At a later time, a Builder or a Script can arm the puzzle +and spawn all puzzle parts in their respective +locations (See @armpuzzle). + +A regular player can collect the puzzle parts and combine +them (See use command). If player has specified +all pieces, the puzzle is considered solved and all +its puzzle parts are destroyed while the puzzle results +are spawened on their corresponding location. + +Installation: + +Add the PuzzleSystemCmdSet to all players. +Alternatively: + + @py self.cmdset.add('evennia.contrib.puzzles.PuzzleSystemCmdSet') + +""" + +import itertools +from random import choice +from evennia import create_object, create_script +from evennia import CmdSet +from evennia import DefaultObject +from evennia import DefaultScript +from evennia import DefaultCharacter +from evennia import DefaultRoom +from evennia import DefaultExit +from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils.utils import inherits_from +from evennia.utils import search, utils, logger +from evennia.prototypes.spawner import spawn + +# Tag used by puzzles +_PUZZLES_TAG_CATEGORY = 'puzzles' +_PUZZLES_TAG_RECIPE = 'puzzle_recipe' +# puzzle part and puzzle result +_PUZZLES_TAG_MEMBER = 'puzzle_member' + +_PUZZLE_DEFAULT_FAIL_USE_MESSAGE = 'You try to utilize %s but nothing happens ... something amiss?' +_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = 'You are a Genius!!!' +_PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE = "|c{caller}|n performs some kind of tribal dance and |y{result_names}|n seems to appear from thin air" + +# ----------- UTILITY FUNCTIONS ------------ + +def proto_def(obj, with_tags=True): + """ + Basic properties needed to spawn + and compare recipe with candidate part + """ + protodef = { + # TODO: Don't we need to honor ALL properties? attributes, contents, etc. + 'prototype_key': '%s(%s)' % (obj.key, obj.dbref), + 'key': obj.key, + 'typeclass': obj.typeclass_path, + 'desc': obj.db.desc, + 'location': obj.location, + 'home': obj.home, + 'locks': ';'.join(obj.locks.all()), + 'permissions': obj.permissions.all()[:], + } + if with_tags: + tags = obj.tags.all(return_key_and_category=True) + tags = [(t[0], t[1], None) for t in tags] + tags.append((_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY, None)) + protodef['tags'] = tags + return protodef + + +def maskout_protodef(protodef, mask): + """ + Returns a new protodef after removing protodef values based on mask + """ + protodef = dict(protodef) + for m in mask: + if m in protodef: + protodef.pop(m) + return protodef + + +# Colorize the default success message +def _colorize_message(msg): + _i = 0 + _colors = ['|r', '|g', '|y'] + _msg = [] + for l in msg: + _msg += _colors[_i] + l + _i = (_i + 1) % len(_colors) + msg = ''.join(_msg) + '|n' + return msg + +_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = _colorize_message(_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE) + +# ------------------------------------------ + +class PuzzleRecipe(DefaultScript): + """ + Definition of a Puzzle Recipe + """ + + def save_recipe(self, puzzle_name, parts, results): + self.db.puzzle_name = str(puzzle_name) + self.db.parts = tuple(parts) + self.db.results = tuple(results) + self.db.mask = tuple() + self.tags.add(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY) + self.db.use_success_message = _PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE + self.db.use_success_location_message = _PUZZLE_DEFAULT_SUCCESS_USE_LOCATION_MESSAGE + + +class CmdCreatePuzzleRecipe(MuxCommand): + """ + Creates a puzzle recipe. + + Each part and result must exist and be placed in their + corresponding location. + + They are all left intact and Caller should explicitly destroy + them. If the /arm switch is used, the specified objects become + puzzle parts ready to be combined and spawn a new result. + + Switches: + arm - the specified objects become puzzle parts as if the puzzle + had been armed explicitly. The results are left intact so + they must be explicitly destroyed. + + Usage: + @puzzle[/arm] name,] = + """ + + key = '@puzzle' + aliases = '@puzzlerecipe' + locks = 'cmd:perm(puzzle) or perm(Builder)' + help_category = 'Puzzles' + + confirm = True + default_confirm = 'no' + + def func(self): + caller = self.caller + + if len(self.lhslist) < 2 \ + or not self.rhs: + string = "Usage: @puzzle name, = " + caller.msg(string) + return + + puzzle_name = self.lhslist[0] + if len(puzzle_name) == 0: + caller.msg('Invalid puzzle name %r.' % puzzle_name) + return + + # if there is another puzzle with same name + # warn user that parts and results will be + # interchangable + _puzzles = search.search_script_attribute( + key='puzzle_name', + value=puzzle_name + ) + _puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles)) + if _puzzles: + confirm = 'There are %d puzzles with the same name.\n' % len(_puzzles) \ + + 'Its parts and results will be interchangeable.\n' \ + + 'Continue yes/[no]? ' + answer = '' + while answer.strip().lower() not in ('y', 'yes', 'n', 'no'): + answer = yield(confirm) + answer = self.default_confirm if answer == '' else answer + if answer.strip().lower() in ('n', 'no'): + caller.msg('Cancelled: no puzzle created.') + return + + def is_valid_obj_location(obj): + valid = True + # Rooms are the only valid locations. + # TODO: other valid locations could be added here. + # Certain locations can be handled accordingly: e.g, + # a part is located in a character's inventory, + # perhaps will translate into the player character + # having the part in his/her inventory while being + # located in the same room where the builder was + # located. + # Parts and results may have different valid locations + if not inherits_from(obj.location, DefaultRoom): + caller.msg('Invalid location for %s' % (obj.key)) + valid = False + return valid + + def is_valid_part_location(part): + return is_valid_obj_location(part) + + def is_valid_result_location(part): + return is_valid_obj_location(part) + + def is_valid_inheritance(obj): + valid = not inherits_from(obj, DefaultCharacter) \ + and not inherits_from(obj, DefaultRoom) \ + and not inherits_from(obj, DefaultExit) + if not valid: + caller.msg('Invalid typeclass for %s' % (obj)) + return valid + + def is_valid_part(part): + return is_valid_inheritance(part) \ + and is_valid_part_location(part) + + def is_valid_result(result): + return is_valid_inheritance(result) \ + and is_valid_result_location(result) + + parts = [] + for objname in self.lhslist[1:]: + obj = caller.search(objname) + if not obj: + return + if not is_valid_part(obj): + return + parts.append(obj) + + results = [] + for objname in self.rhslist: + obj = caller.search(objname) + if not obj: + return + if not is_valid_result(obj): + return + results.append(obj) + + for part in parts: + caller.msg('Part %s(%s)' % (part.name, part.dbref)) + + for result in results: + caller.msg('Result %s(%s)' % (result.name, result.dbref)) + + proto_parts = [proto_def(obj) for obj in parts] + proto_results = [proto_def(obj) for obj in results] + + puzzle = create_script(PuzzleRecipe, key=puzzle_name) + puzzle.save_recipe(puzzle_name, proto_parts, proto_results) + puzzle.locks.add('control:id(%s) or perm(Builder)' % caller.dbref[1:]) + + caller.msg( + "Puzzle |y'%s' |w%s(%s)|n has been created |gsuccessfully|n." + % (puzzle.db.puzzle_name, puzzle.name, puzzle.dbref)) + caller.msg( + 'You may now dispose all parts and results. ' + 'Typically, results and parts are useless afterwards.\n' + 'Remember to add a "success message" via:\n' + ' @puzzleedit #dbref/use_success_message = \n' + 'You are now able to arm this puzzle using Builder command:\n' + ' @armpuzzle \n' + ) + + +class CmdEditPuzzle(MuxCommand): + """ + Edits puzzle properties + + Usage: + @puzzleedit[/delete] <#dbref> + @puzzleedit <#dbref>/use_success_message = + @puzzleedit <#dbref>/use_success_location_message = + @puzzleedit <#dbref>/mask = attr1[,attr2,...]> + @puzzleedit[/addpart] <#dbref> = + @puzzleedit[/delpart] <#dbref> = + @puzzleedit[/addresult] <#dbref> = + @puzzleedit[/delresult] <#dbref> = + + Switches: + addpart - adds parts to the puzzle + delpart - removes parts from the puzzle + addresult - adds results to the puzzle + delresult - removes results from the puzzle + delete - deletes the recipe. Existing parts and results aren't modified + + mask - attributes to exclude during matching (e.g. location, desc, etc.) + use_success_location_message containing {result_names} and {caller} will automatically be replaced with correct values. Both are optional. + + When removing parts/results, it's possible to remove all. + + """ + + key = '@puzzleedit' + locks = 'cmd:perm(puzzleedit) or perm(Builder)' + help_category = 'Puzzles' + + def func(self): + self._USAGE = "Usage: @puzzleedit[/switches] [/attribute = ]" + caller = self.caller + + if not self.lhslist: + caller.msg(self._USAGE) + return + + if '/' in self.lhslist[0]: + recipe_dbref, attr = self.lhslist[0].split('/') + else: + recipe_dbref = self.lhslist[0] + + if not utils.dbref(recipe_dbref): + caller.msg("A puzzle recipe's #dbref must be specified.\n" + self._USAGE) + return + + puzzle = search.search_script(recipe_dbref) + if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe): + caller.msg('%s(%s) is not a puzzle' % (puzzle[0].name, recipe_dbref)) + return + + puzzle = puzzle[0] + puzzle_name_id = '%s(%s)' % (puzzle.name, puzzle.dbref) + + if 'delete' in self.switches: + if not (puzzle.access(caller, 'control') or puzzle.access(caller, 'delete')): + caller.msg("You don't have permission to delete %s." % puzzle_name_id) + return + + puzzle.delete() + caller.msg('%s was deleted' % puzzle_name_id) + return + + elif 'addpart' in self.switches: + objs = self._get_objs() + if objs: + added = self._add_parts(objs, puzzle) + caller.msg('%s were added to parts' % (', '.join(added))) + return + + elif 'delpart' in self.switches: + objs = self._get_objs() + if objs: + removed = self._remove_parts(objs, puzzle) + caller.msg('%s were removed from parts' % (', '.join(removed))) + return + + elif 'addresult' in self.switches: + objs = self._get_objs() + if objs: + added = self._add_results(objs, puzzle) + caller.msg('%s were added to results' % (', '.join(added))) + return + + elif 'delresult' in self.switches: + objs = self._get_objs() + if objs: + removed = self._remove_results(objs, puzzle) + caller.msg('%s were removed from results' % (', '.join(removed))) + return + + else: + # edit attributes + + if not (puzzle.access(caller, 'control') or puzzle.access(caller, 'edit')): + caller.msg("You don't have permission to edit %s." % puzzle_name_id) + return + + if attr == 'use_success_message': + puzzle.db.use_success_message = self.rhs + caller.msg( + "%s use_success_message = %s\n" % (puzzle_name_id, puzzle.db.use_success_message) + ) + return + elif attr == 'use_success_location_message': + puzzle.db.use_success_location_message = self.rhs + caller.msg( + "%s use_success_location_message = %s\n" % (puzzle_name_id, puzzle.db.use_success_location_message) + ) + return + elif attr == 'mask': + puzzle.db.mask = tuple(self.rhslist) + caller.msg( + "%s mask = %r\n" % (puzzle_name_id, puzzle.db.mask) + ) + return + + def _get_objs(self): + if not self.rhslist: + self.caller.msg(self._USAGE) + return + objs = [] + for o in self.rhslist: + obj = self.caller.search(o) + if obj: + objs.append(obj) + return objs + + def _add_objs_to(self, objs, to): + """Adds propto objs to the given set (parts or results)""" + added = [] + toobjs = list(to[:]) + for obj in objs: + protoobj = proto_def(obj) + toobjs.append(protoobj) + added.append(obj.key) + return added, toobjs + + def _remove_objs_from(self, objs, frm): + """Removes propto objs from the given set (parts or results)""" + removed = [] + fromobjs = list(frm[:]) + for obj in objs: + protoobj = proto_def(obj) + if protoobj in fromobjs: + fromobjs.remove(protoobj) + removed.append(obj.key) + return removed, fromobjs + + def _add_parts(self, objs, puzzle): + added, toobjs = self._add_objs_to(objs, puzzle.db.parts) + puzzle.db.parts = tuple(toobjs) + return added + + def _remove_parts(self, objs, puzzle): + removed, fromobjs = self._remove_objs_from(objs, puzzle.db.parts) + puzzle.db.parts = tuple(fromobjs) + return removed + + def _add_results(self, objs, puzzle): + added, toobjs = self._add_objs_to(objs, puzzle.db.results) + puzzle.db.results = tuple(toobjs) + return added + + def _remove_results(self, objs, puzzle): + removed, fromobjs = self._remove_objs_from(objs, puzzle.db.results) + puzzle.db.results = tuple(fromobjs) + return removed + + +class CmdArmPuzzle(MuxCommand): + """ + Arms a puzzle by spawning all its parts + """ + + key = '@armpuzzle' + locks = 'cmd:perm(armpuzzle) or perm(Builder)' + help_category = 'Puzzles' + + def func(self): + caller = self.caller + + if self.args is None or not utils.dbref(self.args): + caller.msg("A puzzle recipe's #dbref must be specified") + return + + puzzle = search.search_script(self.args) + if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe): + caller.msg('Invalid puzzle %r' % (self.args)) + return + + puzzle = puzzle[0] + caller.msg( + "Puzzle Recipe %s(%s) '%s' found.\nSpawning %d parts ..." % ( + puzzle.name, puzzle.dbref, puzzle.db.puzzle_name, len(puzzle.db.parts))) + + for proto_part in puzzle.db.parts: + part = spawn(proto_part)[0] + caller.msg("Part %s(%s) spawned and placed at %s(%s)" % (part.name, part.dbref, part.location, part.location.dbref)) + part.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY) + part.db.puzzle_name = puzzle.db.puzzle_name + + caller.msg("Puzzle armed |gsuccessfully|n.") + + +def _lookups_parts_puzzlenames_protodefs(parts): + # Create lookup dicts by part's dbref and by puzzle_name(tags) + parts_dict = dict() + puzzlename_tags_dict = dict() + puzzle_ingredients = dict() + for part in parts: + parts_dict[part.dbref] = part + protodef = proto_def(part, with_tags=False) + # remove 'prototype_key' as it will prevent equality + del(protodef['prototype_key']) + puzzle_ingredients[part.dbref] = protodef + tags_categories = part.tags.all(return_key_and_category=True) + for tag, category in tags_categories: + if category != _PUZZLES_TAG_CATEGORY: + continue + if tag not in puzzlename_tags_dict: + puzzlename_tags_dict[tag] = [] + puzzlename_tags_dict[tag].append(part.dbref) + return parts_dict, puzzlename_tags_dict, puzzle_ingredients + + +def _puzzles_by_names(names): + # Find all puzzles by puzzle name (i.e. tag name) + puzzles = [] + for puzzle_name in names: + _puzzles = search.search_script_attribute( + key='puzzle_name', + value=puzzle_name + ) + _puzzles = list(filter(lambda p: isinstance(p, PuzzleRecipe), _puzzles)) + if not _puzzles: + continue + else: + puzzles.extend(_puzzles) + return puzzles + +def _matching_puzzles(puzzles, puzzlename_tags_dict, puzzle_ingredients): + # Check if parts can be combined to solve a puzzle + matched_puzzles = dict() + for puzzle in puzzles: + puzzle_protoparts = list(puzzle.db.parts[:]) + puzzle_mask = puzzle.db.mask[:] + # remove tags and prototype_key as they prevent equality + for i, puzzle_protopart in enumerate(puzzle_protoparts[:]): + del(puzzle_protopart['tags']) + del(puzzle_protopart['prototype_key']) + puzzle_protopart = maskout_protodef(puzzle_protopart, puzzle_mask) + puzzle_protoparts[i] = puzzle_protopart + + matched_dbrefparts = [] + parts_dbrefs = puzzlename_tags_dict[puzzle.db.puzzle_name] + for part_dbref in parts_dbrefs: + protopart = puzzle_ingredients[part_dbref] + protopart = maskout_protodef(protopart, puzzle_mask) + if protopart in puzzle_protoparts: + puzzle_protoparts.remove(protopart) + matched_dbrefparts.append(part_dbref) + else: + if len(puzzle_protoparts) == 0: + matched_puzzles[puzzle.dbref] = matched_dbrefparts + return matched_puzzles + + +class CmdUsePuzzleParts(MuxCommand): + """ + Searches for all puzzles whose parts + match the given set of objects. If + there are matching puzzles, the result + objects are spawned in their corresponding + location if all parts have been passed in. + + Usage: + use ] + """ + + key = 'use' + aliases = 'combine' + locks = 'cmd:pperm(use) or pperm(Player)' + help_category = 'Puzzles' + + def func(self): + caller = self.caller + + if not self.lhs: + caller.msg('Use what?') + return + + many = 'these' if len(self.lhslist) > 1 else 'this' + + # either all are parts, or abort finding matching puzzles + parts = [] + partnames = self.lhslist[:] + for partname in partnames: + part = caller.search( + partname, + multimatch_string='Which %s. There are many.\n' % (partname), + nofound_string='There is no %s around.' % (partname) + ) + + if not part: + return + + if not part.tags.get(_PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY): + + # not a puzzle part ... abort + caller.msg('You have no idea how %s can be used' % (many)) + return + + # a valid part + parts.append(part) + + # Create lookup dicts by part's dbref and by puzzle_name(tags) + parts_dict, puzzlename_tags_dict, puzzle_ingredients = \ + _lookups_parts_puzzlenames_protodefs(parts) + + # Find all puzzles by puzzle name (i.e. tag name) + puzzles = _puzzles_by_names(puzzlename_tags_dict.keys()) + + logger.log_info("PUZZLES %r" % ([(p.dbref, p.db.puzzle_name) for p in puzzles])) + + # Create lookup dict of puzzles by dbref + puzzles_dict = dict((puzzle.dbref, puzzle) for puzzle in puzzles) + # Check if parts can be combined to solve a puzzle + matched_puzzles = _matching_puzzles( + puzzles, puzzlename_tags_dict, puzzle_ingredients) + + if len(matched_puzzles) == 0: + # TODO: we could use part.fail_message instead, if there was one + # random part falls and lands on your feet + # random part hits you square on the face + caller.msg(_PUZZLE_DEFAULT_FAIL_USE_MESSAGE % (many)) + return + + puzzletuples = sorted(matched_puzzles.items(), key=lambda t: len(t[1]), reverse=True) + + logger.log_info("MATCHED PUZZLES %r" % (puzzletuples)) + + # sort all matched puzzles and pick largest one(s) + puzzledbref, matched_dbrefparts = puzzletuples[0] + nparts = len(matched_dbrefparts) + puzzle = puzzles_dict[puzzledbref] + largest_puzzles = list(itertools.takewhile(lambda t: len(t[1]) == nparts, puzzletuples)) + + # if there are more than one, choose one at random. + # we could show the names of all those that can be resolved + # but that would give away that there are other puzzles that + # can be resolved with the same parts. + # just hint how many. + if len(largest_puzzles) > 1: + caller.msg( + 'Your gears start turning and %d different ideas come to your mind ...\n' + % (len(largest_puzzles)) + ) + puzzletuple = choice(largest_puzzles) + puzzle = puzzles_dict[puzzletuple[0]] + caller.msg("You try %s ..." % (puzzle.db.puzzle_name)) + + # got one, spawn its results + result_names = [] + for proto_result in puzzle.db.results: + result = spawn(proto_result)[0] + result.tags.add(puzzle.db.puzzle_name, category=_PUZZLES_TAG_CATEGORY) + result.db.puzzle_name = puzzle.db.puzzle_name + result_names.append(result.name) + + # Destroy all parts used + for dbref in matched_dbrefparts: + parts_dict[dbref].delete() + + result_names = ', '.join(result_names) + caller.msg(puzzle.db.use_success_message) + caller.location.msg_contents( + puzzle.db.use_success_location_message.format( + caller=caller, result_names=result_names), + exclude=(caller,) + ) + + +class CmdListPuzzleRecipes(MuxCommand): + """ + Searches for all puzzle recipes + + Usage: + @lspuzzlerecipes + """ + + key = '@lspuzzlerecipes' + locks = 'cmd:perm(lspuzzlerecipes) or perm(Builder)' + help_category = 'Puzzles' + + def func(self): + caller = self.caller + + recipes = search.search_script_tag( + _PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY) + + div = "-" * 60 + text = [div] + msgf_recipe = "Puzzle |y'%s' %s(%s)|n" + msgf_item = "%2s|c%15s|n: |w%s|n" + for recipe in recipes: + text.append(msgf_recipe % (recipe.db.puzzle_name, recipe.name, recipe.dbref)) + text.append('Success Caller message:\n' + recipe.db.use_success_message + '\n') + text.append('Success Location message:\n' + recipe.db.use_success_location_message + '\n') + text.append('Mask:\n' + str(recipe.db.mask) + '\n') + text.append('Parts') + for protopart in recipe.db.parts[:]: + mark = '-' + for k, v in protopart.items(): + text.append(msgf_item % (mark, k, v)) + mark = '' + text.append('Results') + for protoresult in recipe.db.results[:]: + mark = '-' + for k, v in protoresult.items(): + text.append(msgf_item % (mark, k, v)) + mark = '' + else: + text.append(div) + text.append('Found |r%d|n puzzle(s).' % (len(recipes))) + text.append(div) + caller.msg('\n'.join(text)) + + +class CmdListArmedPuzzles(MuxCommand): + """ + Searches for all armed puzzles + + Usage: + @lsarmedpuzzles + """ + + key = '@lsarmedpuzzles' + locks = 'cmd:perm(lsarmedpuzzles) or perm(Builder)' + help_category = 'Puzzles' + + def func(self): + caller = self.caller + + armed_puzzles = search.search_tag( + _PUZZLES_TAG_MEMBER, category=_PUZZLES_TAG_CATEGORY) + + armed_puzzles = dict((k, list(g)) for k, g in itertools.groupby( + armed_puzzles, + lambda ap: ap.db.puzzle_name)) + + div = '-' * 60 + msgf_pznm = "Puzzle name: |y%s|n" + msgf_item = "|m%25s|w(%s)|n at |c%25s|w(%s)|n" + text = [div] + for pzname, items in armed_puzzles.items(): + text.append(msgf_pznm % (pzname)) + for item in items: + text.append(msgf_item % ( + item.name, item.dbref, + item.location.name, item.location.dbref)) + else: + text.append(div) + text.append('Found |r%d|n armed puzzle(s).' % (len(armed_puzzles))) + text.append(div) + caller.msg('\n'.join(text)) + + +class PuzzleSystemCmdSet(CmdSet): + """ + CmdSet to create, arm and resolve Puzzles + """ + + def at_cmdset_creation(self): + super(PuzzleSystemCmdSet, self).at_cmdset_creation() + + self.add(CmdCreatePuzzleRecipe()) + self.add(CmdEditPuzzle()) + self.add(CmdArmPuzzle()) + self.add(CmdListPuzzleRecipes()) + self.add(CmdListArmedPuzzles()) + self.add(CmdUsePuzzleParts()) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 9695ce63df..6e3f689985 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1583,7 +1583,7 @@ class TestFieldFillFunc(EvenniaTest): def test_field_functions(self): self.assertTrue(fieldfill.form_template_to_dict(FIELD_TEST_TEMPLATE) == FIELD_TEST_DATA) - + # Test of the unixcommand module from evennia.contrib.unixcommand import UnixCommand @@ -1717,6 +1717,961 @@ class TestRandomStringGenerator(EvenniaTest): SIMPLE_GENERATOR.get() +# Test of the Puzzles module + +import itertools +from evennia.contrib import puzzles +from evennia.utils import search +from evennia.utils.utils import inherits_from + +class TestPuzzles(CommandTest): + + def setUp(self): + super(TestPuzzles, self).setUp() + self.steel = create_object( + self.object_typeclass, + key='steel', location=self.char1.location) + self.flint = create_object( + self.object_typeclass, + key='flint', location=self.char1.location) + self.fire = create_object( + self.object_typeclass, + key='fire', location=self.char1.location) + self.steel.tags.add('tag-steel') + self.steel.tags.add('tag-steel', category='tagcat') + self.flint.tags.add('tag-flint') + self.flint.tags.add('tag-flint', category='tagcat') + self.fire.tags.add('tag-fire') + self.fire.tags.add('tag-fire', category='tagcat') + + def _assert_msg_matched(self, msg, regexs, re_flags=0): + matches = [] + for regex in regexs: + m = re.search(regex, msg, re_flags) + self.assertIsNotNone(m, "%r didn't match %r" % (regex, msg)) + matches.append(m) + return matches + + def _assert_recipe(self, name, parts, results, and_destroy_it=True, expected_count=1): + + def _keys(items): + return [item['key'] for item in items] + + recipes = search.search_script_tag('', category=puzzles._PUZZLES_TAG_CATEGORY) + self.assertEqual(expected_count, len(recipes)) + self.assertEqual(name, recipes[expected_count-1].db.puzzle_name) + self.assertEqual(parts, _keys(recipes[expected_count-1].db.parts)) + self.assertEqual(results, _keys(recipes[expected_count-1].db.results)) + self.assertEqual( + puzzles._PUZZLES_TAG_RECIPE, + recipes[expected_count-1].tags.get(category=puzzles._PUZZLES_TAG_CATEGORY) + ) + recipe_dbref = recipes[expected_count-1].dbref + if and_destroy_it: + recipes[expected_count-1].delete() + return recipe_dbref if not and_destroy_it else None + + def _assert_no_recipes(self): + self.assertEqual( + 0, + len(search.search_script_tag('', category=puzzles._PUZZLES_TAG_CATEGORY)) + ) + + # good recipes + def _good_recipe(self, name, parts, results, and_destroy_it=True, expected_count=1): + regexs = [] + for p in parts: + regexs.append(r'^Part %s\(#\d+\)$' % (p)) + for r in results: + regexs.append(r'^Result %s\(#\d+\)$' % (r)) + regexs.append(r"^Puzzle '%s' %s\(#\d+\) has been created successfully.$" % (name, name)) + lhs = [name] + parts + cmdstr = ','.join(lhs) + '=' + ','.join(results) + msg = self.call( + puzzles.CmdCreatePuzzleRecipe(), + cmdstr, + caller=self.char1 + ) + recipe_dbref = self._assert_recipe(name, parts, results, and_destroy_it, expected_count) + matches = self._assert_msg_matched(msg, regexs, re_flags=re.MULTILINE | re.DOTALL) + return recipe_dbref + + def _check_room_contents(self, expected, check_test_tags=False): + by_obj_key = lambda o: o.key + room1_contents = sorted(self.room1.contents, key=by_obj_key) + for key, grp in itertools.groupby(room1_contents, by_obj_key): + if key in expected: + grp = list(grp) + self.assertEqual(expected[key], len(grp), + "Expected %d but got %d for %s" % (expected[key], len(grp), key)) + if check_test_tags: + for gi in grp: + tags = gi.tags.all(return_key_and_category=True) + self.assertIn(('tag-' + gi.key, None), tags) + self.assertIn(('tag-' + gi.key, 'tagcat'), tags) + + def _arm(self, recipe_dbref, name, parts): + regexs = [ + r"^Puzzle Recipe %s\(#\d+\) '%s' found.$" % (name, name), + r"^Spawning %d parts ...$" % (len(parts)), + ] + for p in parts: + regexs.append(r'^Part %s\(#\d+\) spawned .*$' % (p)) + regexs.append(r"^Puzzle armed successfully.$") + msg = self.call( + puzzles.CmdArmPuzzle(), + recipe_dbref, + caller=self.char1 + ) + matches = self._assert_msg_matched(msg, regexs, re_flags=re.MULTILINE | re.DOTALL) + + def test_cmdset_puzzle(self): + self.char1.cmdset.add('evennia.contrib.puzzles.PuzzleSystemCmdSet') + # FIXME: testing nothing, this is just to bump up coverage + + def test_cmd_puzzle(self): + self._assert_no_recipes() + + # bad syntax + def _bad_syntax(cmdstr): + self.call( + puzzles.CmdCreatePuzzleRecipe(), + cmdstr, + 'Usage: @puzzle name, = ', + caller=self.char1 + ) + + _bad_syntax('') + _bad_syntax('=') + _bad_syntax('nothing =') + _bad_syntax('= nothing') + _bad_syntax('nothing') + _bad_syntax(',nothing') + _bad_syntax('name, nothing') + _bad_syntax('name, nothing =') + + self._assert_no_recipes() + + self._good_recipe('makefire', ['steel', 'flint'], ['fire', 'steel', 'flint']) + self._good_recipe('hot steels', ['steel', 'fire'], ['steel', 'fire']) + self._good_recipe('furnace', ['steel', 'steel', 'fire'], ['steel', 'steel', 'fire', 'fire', 'fire', 'fire']) + + # bad recipes + def _bad_recipe(name, parts, results, fail_regex): + cmdstr = ','.join([name] + parts) \ + + '=' + ','.join(results) + msg = self.call( + puzzles.CmdCreatePuzzleRecipe(), + cmdstr, + caller=self.char1 + ) + self._assert_no_recipes() + self.assertIsNotNone(re.match(fail_regex, msg), msg) + + _bad_recipe('name', ['nothing'], ['neither'], r"Could not find 'nothing'.") + _bad_recipe('name', ['steel'], ['nothing'], r"Could not find 'nothing'.") + _bad_recipe('', ['steel', 'fire'], ['steel', 'fire'], r"^Invalid puzzle name ''.") + self.steel.location = self.char1 + _bad_recipe('name', ['steel'], ['fire'], r"^Invalid location for steel$") + _bad_recipe('name', ['flint'], ['steel'], r"^Invalid location for steel$") + _bad_recipe('name', ['self'], ['fire'], r"^Invalid typeclass for Char$") + _bad_recipe('name', ['here'], ['fire'], r"^Invalid typeclass for Room$") + + self._assert_no_recipes() + + def test_cmd_armpuzzle(self): + # bad arms + self.call( + puzzles.CmdArmPuzzle(), + '1', + "A puzzle recipe's #dbref must be specified", + caller=self.char1 + ) + self.call( + puzzles.CmdArmPuzzle(), + '#1', + "Invalid puzzle '#1'", + caller=self.char1 + ) + + recipe_dbref = self._good_recipe('makefire', ['steel', 'flint'], ['fire', 'steel', 'flint'], and_destroy_it=False) + + # delete proto parts and proto result + self.steel.delete() + self.flint.delete() + self.fire.delete() + + # good arm + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + self._check_room_contents({'steel': 1, 'flint': 1}, check_test_tags=True) + + def _use(self, cmdstr, expmsg): + msg = self.call( + puzzles.CmdUsePuzzleParts(), + cmdstr, + expmsg, + caller=self.char1 + ) + return msg + + def test_cmd_use(self): + + self._use('', 'Use what?') + self._use('something', 'There is no something around.') + self._use('steel', 'You have no idea how this can be used') + self._use('steel flint', 'There is no steel flint around.') + self._use('steel, flint', 'You have no idea how these can be used') + + recipe_dbref = self._good_recipe(unicode('makefire'), ['steel', 'flint'], ['fire'] , and_destroy_it=False) + recipe2_dbref = self._good_recipe('makefire2', ['steel', 'flint'], ['fire'] , and_destroy_it=False, + expected_count=2) + + # although there is steel and flint + # those aren't valid puzzle parts because + # the puzzle hasn't been armed + self._use('steel', 'You have no idea how this can be used') + self._use('steel, flint', 'You have no idea how these can be used') + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + self._check_room_contents({'steel': 2, 'flint': 2}, check_test_tags=True) + + # there are duplicated objects now + self._use('steel', 'Which steel. There are many') + self._use('flint', 'Which flint. There are many') + + # delete proto parts and proto results + self.steel.delete() + self.flint.delete() + self.fire.delete() + + # solve puzzle + self._use('steel, flint', 'You are a Genius') + self.assertEqual(1, + len(list(filter( + lambda o: o.key == 'fire' \ + and ('makefire', puzzles._PUZZLES_TAG_CATEGORY) \ + in o.tags.all(return_key_and_category=True) \ + and (puzzles._PUZZLES_TAG_MEMBER, puzzles._PUZZLES_TAG_CATEGORY) \ + in o.tags.all(return_key_and_category=True), + self.room1.contents)))) + self._check_room_contents({'steel': 0, 'flint': 0, 'fire': 1}, check_test_tags=True) + + # trying again will fail as it was resolved already + # and the parts were destroyed + self._use('steel, flint', 'There is no steel around') + self._use('flint, steel', 'There is no flint around') + + # arm same puzzle twice so there are duplicated parts + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + self._check_room_contents({'steel': 2, 'flint': 2, 'fire': 1}, check_test_tags=True) + + # try solving with multiple parts but incomplete set + self._use('1-steel, 2-steel', 'You try to utilize these but nothing happens ... something amiss?') + + # arm the other puzzle. Their parts are identical + self._arm(recipe2_dbref, 'makefire2', ['steel', 'flint']) + self._check_room_contents({'steel': 3, 'flint': 3, 'fire': 1}, check_test_tags=True) + + # solve with multiple parts for + # multiple puzzles. Both can be solved but + # only one is. + self._use( + '1-steel, 2-flint, 3-steel, 3-flint', + 'Your gears start turning and 2 different ideas come to your mind ... ') + self._check_room_contents({'steel': 2, 'flint': 2, 'fire': 2}, check_test_tags=True) + + self.room1.msg_contents = Mock() + + # solve all + self._use('1-steel, 1-flint', 'You are a Genius') + self.room1.msg_contents.assert_called_once_with('|cChar|n performs some kind of tribal dance and |yfire|n seems to appear from thin air', exclude=(self.char1,)) + self._use('steel, flint', 'You are a Genius') + self._check_room_contents({'steel': 0, 'flint': 0, 'fire': 4}, check_test_tags=True) + + def test_puzzleedit(self): + recipe_dbref = self._good_recipe('makefire', ['steel', 'flint'], ['fire'] , and_destroy_it=False) + + def _puzzleedit(swt, dbref, args, expmsg): + if (swt is None) and (dbref is None) and (args is None): + cmdstr = '' + else: + cmdstr = '%s %s%s' % (swt, dbref, args) + self.call( + puzzles.CmdEditPuzzle(), + cmdstr, + expmsg, + caller=self.char1 + ) + + # delete proto parts and proto results + self.steel.delete() + self.flint.delete() + self.fire.delete() + + # bad syntax + _puzzleedit(None, None, None, "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit('', '1', '', "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit('', '', '', "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit('', recipe_dbref, 'dummy', "A puzzle recipe's #dbref must be specified.\nUsage: @puzzleedit") + _puzzleedit('', self.script.dbref, '', 'Script(#1) is not a puzzle') + + # edit use_success_message and use_success_location_message + _puzzleedit('', recipe_dbref, '/use_success_message = Yes!', 'makefire(%s) use_success_message = Yes!' % recipe_dbref) + _puzzleedit('', recipe_dbref, '/use_success_location_message = {result_names} Yeah baby! {caller}', 'makefire(%s) use_success_location_message = {result_names} Yeah baby! {caller}' % recipe_dbref) + + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + self.room1.msg_contents = Mock() + self._use('steel, flint', 'Yes!') + self.room1.msg_contents.assert_called_once_with('fire Yeah baby! Char', exclude=(self.char1,)) + self.room1.msg_contents.reset_mock() + + # edit mask: exclude location and desc during matching + _puzzleedit('', recipe_dbref, '/mask = location,desc', + "makefire(%s) mask = ('location', 'desc')" % recipe_dbref) + + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + # change location and desc + self.char1.search('steel').db.desc = 'A solid bar of steel' + self.char1.search('steel').location = self.char1 + self.char1.search('flint').db.desc = 'A flint steel' + self.char1.search('flint').location = self.char1 + self._use('steel, flint', 'Yes!') + self.room1.msg_contents.assert_called_once_with('fire Yeah baby! Char', exclude=(self.char1,)) + + # delete + _puzzleedit('/delete', recipe_dbref, '', 'makefire(%s) was deleted' % recipe_dbref) + self._assert_no_recipes() + + def test_puzzleedit_add_remove_parts_results(self): + recipe_dbref = self._good_recipe('makefire', ['steel', 'flint'], ['fire'] , and_destroy_it=False) + + def _puzzleedit(swt, dbref, rhslist, expmsg): + cmdstr = '%s %s = %s' % (swt, dbref, ', '.join(rhslist)) + self.call( + puzzles.CmdEditPuzzle(), + cmdstr, + expmsg, + caller=self.char1 + ) + + red_steel = create_object( + self.object_typeclass, + key='red steel', location=self.char1.location) + smoke = create_object( + self.object_typeclass, + key='smoke', location=self.char1.location) + + _puzzleedit('/addresult', recipe_dbref, ['smoke'], 'smoke were added to results') + _puzzleedit('/addpart', recipe_dbref, ['red steel', 'steel'], 'red steel, steel were added to parts') + + # create a box so we can put all objects in + # so that they can't be found during puzzle resolution + self.box = create_object( + self.object_typeclass, + key='box', location=self.char1.location) + def _box_all(): + for o in self.room1.contents: + if o not in [self.char1, self.char2, self.exit, + self.obj1, self.obj2, self.box]: + o.location = self.box + _box_all() + + self._arm(recipe_dbref, 'makefire', ['steel', 'flint', 'red steel', 'steel']) + self._check_room_contents({ + 'steel': 2, + 'red steel': 1, + 'flint': 1, + }) + self._use('1-steel, flint', 'You try to utilize these but nothing happens ... something amiss?') + self._use('1-steel, flint, red steel, 3-steel', 'You are a Genius') + self._check_room_contents({ + 'smoke': 1, + 'fire': 1 + }) + _box_all() + + self.fire.location = self.room1 + self.steel.location = self.room1 + + _puzzleedit('/delresult', recipe_dbref, ['fire'], 'fire were removed from results') + _puzzleedit('/delpart', recipe_dbref, ['steel', 'steel'], 'steel, steel were removed from parts') + + _box_all() + + self._arm(recipe_dbref, 'makefire', ['flint', 'red steel']) + self._check_room_contents({ + 'red steel': 1, + 'flint': 1, + }) + self._use('red steel, flint', 'You are a Genius') + self._check_room_contents({ + 'smoke': 1, + 'fire': 0 + }) + + def test_lspuzzlerecipes_lsarmedpuzzles(self): + msg = self.call( + puzzles.CmdListPuzzleRecipes(), + '', + caller=self.char1 + ) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^Found 0 puzzle\(s\)\.$", + r"-+$", + ], + re.MULTILINE | re.DOTALL + ) + + recipe_dbref = self._good_recipe( + 'makefire', ['steel', 'flint'], ['fire'], + and_destroy_it=False) + + msg = self.call( + puzzles.CmdListPuzzleRecipes(), + '', + caller=self.char1 + ) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^Puzzle 'makefire'.*$", + r"^Success Caller message:$", + r"^Success Location message:$", + r"^Mask:$", + r"^Parts$", + r"^.*key: steel$", + r"^.*key: flint$", + r"^Results$", + r"^.*key: fire$", + r"^.*key: steel$", + r"^.*key: flint$", + r"^-+$", + r"^Found 1 puzzle\(s\)\.$", + r"^-+$", + ], + re.MULTILINE | re.DOTALL + ) + + msg = self.call( + puzzles.CmdListArmedPuzzles(), + '', + caller=self.char1 + ) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^-+$", + r"^Found 0 armed puzzle\(s\)\.$", + r"^-+$" + ], + re.MULTILINE | re.DOTALL + ) + + self._arm(recipe_dbref, 'makefire', ['steel', 'flint']) + + msg = self.call( + puzzles.CmdListArmedPuzzles(), + '', + caller=self.char1 + ) + self._assert_msg_matched( + msg, + [ + r"^-+$", + r"^Puzzle name: makefire$", + r"^.*steel.* at \s+ Room.*$", + r"^.*flint.* at \s+ Room.*$", + r"^Found 1 armed puzzle\(s\)\.$", + r"^-+$", + ], + re.MULTILINE | re.DOTALL + ) + + def test_e2e(self): + + def _destroy_objs_in_room(keys): + for obj in self.room1.contents: + if obj.key in keys: + obj.delete() + + # parts don't survive resolution + # but produce a large result set + tree = create_object( + self.object_typeclass, + key='tree', location=self.char1.location) + axe = create_object( + self.object_typeclass, + key='axe', location=self.char1.location) + sweat = create_object( + self.object_typeclass, + key='sweat', location=self.char1.location) + dull_axe = create_object( + self.object_typeclass, + key='dull axe', location=self.char1.location) + timber = create_object( + self.object_typeclass, + key='timber', location=self.char1.location) + log = create_object( + self.object_typeclass, + key='log', location=self.char1.location) + parts = ['tree', 'axe'] + results = (['sweat'] * 10) + ['dull axe'] + (['timber'] * 20) + (['log'] * 50) + recipe_dbref = self._good_recipe( + 'lumberjack', + parts, results, + and_destroy_it=False + ) + + _destroy_objs_in_room(set(parts + results)) + + sps = sorted(parts) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + expected.update({r: 0 for r in set(results)}) + + self._arm(recipe_dbref, 'lumberjack', parts) + self._check_room_contents(expected) + + self._use(','.join(parts), 'You are a Genius') + srs = sorted(set(results)) + expected = {(key, len(list(grp))) for key, grp in itertools.groupby(srs)} + expected.update({p: 0 for p in set(parts)}) + self._check_room_contents(expected) + + # parts also appear in results + # causing a new puzzle to be armed 'automatically' + # i.e. the puzzle is self-sustaining + hole = create_object( + self.object_typeclass, + key='hole', location=self.char1.location) + shovel = create_object( + self.object_typeclass, + key='shovel', location=self.char1.location) + dirt = create_object( + self.object_typeclass, + key='dirt', location=self.char1.location) + + parts = ['shovel', 'hole'] + results = ['dirt', 'hole', 'shovel'] + recipe_dbref = self._good_recipe( + 'digger', + parts, results, + and_destroy_it=False, + expected_count=2 + ) + + _destroy_objs_in_room(set(parts + results)) + + nresolutions = 0 + + sps = sorted(set(parts)) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + expected.update({'dirt': nresolutions}) + + self._arm(recipe_dbref, 'digger', parts) + self._check_room_contents(expected) + + for i in range(10): + self._use(','.join(parts), 'You are a Genius') + nresolutions += 1 + expected.update({'dirt': nresolutions}) + self._check_room_contents(expected) + + # Uppercase puzzle name + balloon = create_object( + self.object_typeclass, + key='Balloon', location=self.char1.location) + parts = ['Balloon'] + results = ['Balloon'] + recipe_dbref = self._good_recipe( + 'boom!!!', + parts, results, + and_destroy_it=False, + expected_count=3 + ) + + _destroy_objs_in_room(set(parts + results)) + + sps = sorted(parts) + expected = {key: len(list(grp)) for key, grp in itertools.groupby(sps)} + + self._arm(recipe_dbref, 'boom!!!', parts) + self._check_room_contents(expected) + + self._use(','.join(parts), 'You are a Genius') + srs = sorted(set(results)) + expected = {(key, len(list(grp))) for key, grp in itertools.groupby(srs)} + self._check_room_contents(expected) + + def test_e2e_accumulative(self): + flashlight = create_object( + self.object_typeclass, + key='flashlight', location=self.char1.location) + flashlight_w_1 = create_object( + self.object_typeclass, + key='flashlight-w-1', location=self.char1.location) + flashlight_w_2 = create_object( + self.object_typeclass, + key='flashlight-w-2', location=self.char1.location) + flashlight_w_3 = create_object( + self.object_typeclass, + key='flashlight-w-3', + location=self.char1.location) + battery = create_object( + self.object_typeclass, + key='battery', location=self.char1.location) + + battery.tags.add('flashlight-1', category=puzzles._PUZZLES_TAG_CATEGORY) + battery.tags.add('flashlight-2', category=puzzles._PUZZLES_TAG_CATEGORY) + battery.tags.add('flashlight-3', category=puzzles._PUZZLES_TAG_CATEGORY) + + # TODO: instead of tagging each flashlight, + # arm and resolve each puzzle in order so they all + # are tagged correctly + # it will be necessary to add/remove parts/results because + # each battery is supposed to be consumed during resolution + # as the new flashlight has one more battery than before + flashlight_w_1.tags.add('flashlight-2', category=puzzles._PUZZLES_TAG_CATEGORY) + flashlight_w_2.tags.add('flashlight-3', category=puzzles._PUZZLES_TAG_CATEGORY) + + recipe_fl1_dbref = self._good_recipe('flashlight-1', ['flashlight', 'battery'], ['flashlight-w-1'] , and_destroy_it=False, + expected_count=1) + recipe_fl2_dbref = self._good_recipe('flashlight-2', ['flashlight-w-1', 'battery'], ['flashlight-w-2'] , and_destroy_it=False, + expected_count=2) + recipe_fl3_dbref = self._good_recipe('flashlight-3', ['flashlight-w-2', 'battery'], ['flashlight-w-3'] , and_destroy_it=False, + expected_count=3) + + # delete protoparts + for obj in [battery, flashlight, flashlight_w_1, + flashlight_w_2, flashlight_w_3]: + obj.delete() + + def _group_parts(parts, excluding=set()): + group = dict() + dbrefs = dict() + for o in self.room1.contents: + if o.key in parts and o.dbref not in excluding: + if o.key not in group: + group[o.key] = [] + group[o.key].append(o.dbref) + dbrefs[o.dbref] = o + return group, dbrefs + + # arm each puzzle and group its parts + self._arm(recipe_fl1_dbref, 'flashlight-1', ['battery', 'flashlight']) + fl1_parts, fl1_dbrefs = _group_parts(['battery', 'flashlight']) + self._arm(recipe_fl2_dbref, 'flashlight-2', ['battery', 'flashlight-w-1']) + fl2_parts, fl2_dbrefs = _group_parts(['battery', 'flashlight-w-1'], excluding=fl1_dbrefs.keys()) + self._arm(recipe_fl3_dbref, 'flashlight-3', ['battery', 'flashlight-w-2']) + fl3_parts, fl3_dbrefs = _group_parts(['battery', 'flashlight-w-2'], excluding=set(fl1_dbrefs.keys() + fl2_dbrefs.keys())) + + self._check_room_contents({ + 'battery': 3, + 'flashlight': 1, + 'flashlight-w-1': 1, + 'flashlight-w-2': 1, + 'flashlight-w-3': 0 + }) + + # all batteries have identical protodefs + battery_1 = fl1_dbrefs[fl1_parts['battery'][0]] + battery_2 = fl2_dbrefs[fl2_parts['battery'][0]] + battery_3 = fl3_dbrefs[fl3_parts['battery'][0]] + protodef_battery_1 = puzzles.proto_def(battery_1, with_tags=False) + del(protodef_battery_1['prototype_key']) + protodef_battery_2 = puzzles.proto_def(battery_2, with_tags=False) + del(protodef_battery_2['prototype_key']) + protodef_battery_3 = puzzles.proto_def(battery_3, with_tags=False) + del(protodef_battery_3['prototype_key']) + assert protodef_battery_1 == protodef_battery_2 == protodef_battery_3 + + # each battery can be used in every other puzzle + + b1_parts_dict, b1_puzzlenames, b1_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([battery_1]) + _puzzles = puzzles._puzzles_by_names(b1_puzzlenames.keys()) + assert set(['flashlight-1', 'flashlight-2', 'flashlight-3']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b1_puzzlenames, b1_protodefs) + assert 0 == len(matched_puzzles) + + b2_parts_dict, b2_puzzlenames, b2_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([battery_2]) + _puzzles = puzzles._puzzles_by_names(b2_puzzlenames.keys()) + assert set(['flashlight-1', 'flashlight-2', 'flashlight-3']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b2_puzzlenames, b2_protodefs) + assert 0 == len(matched_puzzles) + b3_parts_dict, b3_puzzlenames, b3_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([battery_3]) + _puzzles = puzzles._puzzles_by_names(b3_puzzlenames.keys()) + assert set(['flashlight-1', 'flashlight-2', 'flashlight-3']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, b3_puzzlenames, b3_protodefs) + assert 0 == len(matched_puzzles) + + assert battery_1 == b1_parts_dict.values()[0] + assert battery_2 == b2_parts_dict.values()[0] + assert battery_3 == b3_parts_dict.values()[0] + assert b1_puzzlenames.keys() == b2_puzzlenames.keys() == b3_puzzlenames.keys() + for puzzle_name in ['flashlight-1', 'flashlight-2', 'flashlight-3']: + assert puzzle_name in b1_puzzlenames + assert puzzle_name in b2_puzzlenames + assert puzzle_name in b3_puzzlenames + assert b1_protodefs.values()[0] == b2_protodefs.values()[0] == b3_protodefs.values()[0] \ + == protodef_battery_1 == protodef_battery_2 == protodef_battery_3 + + # all flashlights have similar protodefs except their key + flashlight_1 = fl1_dbrefs[fl1_parts['flashlight'][0]] + flashlight_2 = fl2_dbrefs[fl2_parts['flashlight-w-1'][0]] + flashlight_3 = fl3_dbrefs[fl3_parts['flashlight-w-2'][0]] + protodef_flashlight_1 = puzzles.proto_def(flashlight_1, with_tags=False) + del(protodef_flashlight_1['prototype_key']) + assert protodef_flashlight_1['key'] == 'flashlight' + del(protodef_flashlight_1['key']) + protodef_flashlight_2 = puzzles.proto_def(flashlight_2, with_tags=False) + del(protodef_flashlight_2['prototype_key']) + assert protodef_flashlight_2['key'] == 'flashlight-w-1' + del(protodef_flashlight_2['key']) + protodef_flashlight_3 = puzzles.proto_def(flashlight_3, with_tags=False) + del(protodef_flashlight_3['prototype_key']) + assert protodef_flashlight_3['key'] == 'flashlight-w-2' + del(protodef_flashlight_3['key']) + assert protodef_flashlight_1 == protodef_flashlight_2 == protodef_flashlight_3 + + # each flashlight can only be used in its own puzzle + + f1_parts_dict, f1_puzzlenames, f1_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([flashlight_1]) + _puzzles = puzzles._puzzles_by_names(f1_puzzlenames.keys()) + assert set(['flashlight-1']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f1_puzzlenames, f1_protodefs) + assert 0 == len(matched_puzzles) + f2_parts_dict, f2_puzzlenames, f2_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([flashlight_2]) + _puzzles = puzzles._puzzles_by_names(f2_puzzlenames.keys()) + assert set(['flashlight-2']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f2_puzzlenames, f2_protodefs) + assert 0 == len(matched_puzzles) + f3_parts_dict, f3_puzzlenames, f3_protodefs = puzzles._lookups_parts_puzzlenames_protodefs([flashlight_3]) + _puzzles = puzzles._puzzles_by_names(f3_puzzlenames.keys()) + assert set(['flashlight-3']) \ + == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, f3_puzzlenames, f3_protodefs) + assert 0 == len(matched_puzzles) + + assert flashlight_1 == f1_parts_dict.values()[0] + assert flashlight_2 == f2_parts_dict.values()[0] + assert flashlight_3 == f3_parts_dict.values()[0] + for puzzle_name in set(f1_puzzlenames.keys() + f2_puzzlenames.keys() + f3_puzzlenames.keys()): + assert puzzle_name in ['flashlight-1', 'flashlight-2', 'flashlight-3', 'puzzle_member'] + protodef_flashlight_1['key'] = 'flashlight' + assert f1_protodefs.values()[0] == protodef_flashlight_1 + protodef_flashlight_2['key'] = 'flashlight-w-1' + assert f2_protodefs.values()[0] == protodef_flashlight_2 + protodef_flashlight_3['key'] = 'flashlight-w-2' + assert f3_protodefs.values()[0] == protodef_flashlight_3 + + # each battery can be matched with every other flashlight + # to potentially resolve each puzzle + for batt in [battery_1, battery_2, battery_3]: + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs([batt, flashlight_1]) + assert set([batt.dbref, flashlight_1.dbref]) == set(puzzlenames['flashlight-1']) + assert set([batt.dbref]) == set(puzzlenames['flashlight-2']) + assert set([batt.dbref]) == set(puzzlenames['flashlight-3']) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(['flashlight-1', 'flashlight-2', 'flashlight-3']) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs([batt, flashlight_2]) + assert set([batt.dbref]) == set(puzzlenames['flashlight-1']) + assert set([batt.dbref, flashlight_2.dbref]) == set(puzzlenames['flashlight-2']) + assert set([batt.dbref]) == set(puzzlenames['flashlight-3']) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(['flashlight-1','flashlight-2', 'flashlight-3']) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + parts_dict, puzzlenames, protodefs = puzzles._lookups_parts_puzzlenames_protodefs([batt, flashlight_3]) + assert set([batt.dbref]) == set(puzzlenames['flashlight-1']) + assert set([batt.dbref]) == set(puzzlenames['flashlight-2']) + assert set([batt.dbref, flashlight_3.dbref]) == set(puzzlenames['flashlight-3']) + _puzzles = puzzles._puzzles_by_names(puzzlenames.keys()) + assert set(['flashlight-1','flashlight-2', 'flashlight-3']) == set([p.db.puzzle_name for p in _puzzles]) + matched_puzzles = puzzles._matching_puzzles(_puzzles, puzzlenames, protodefs) + assert 1 == len(matched_puzzles) + + # delete all parts + for part in fl1_dbrefs.values() + fl2_dbrefs.values() + fl3_dbrefs.values(): + part.delete() + + self._check_room_contents({ + 'battery': 0, + 'flashlight': 0, + 'flashlight-w-1': 0, + 'flashlight-w-2': 0, + 'flashlight-w-3': 0 + }) + + # arm first puzzle 3 times and group its parts so we can solve + # all puzzles with the parts from the 1st armed + for i in range(3): + self._arm(recipe_fl1_dbref, 'flashlight-1', ['battery', 'flashlight']) + fl1_parts, fl1_dbrefs = _group_parts(['battery', 'flashlight']) + + # delete the 2 extra flashlights so we can start solving + for flashlight_dbref in fl1_parts['flashlight'][1:]: + fl1_dbrefs[flashlight_dbref].delete() + + self._check_room_contents({ + 'battery': 3, + 'flashlight': 1, + 'flashlight-w-1': 0, + 'flashlight-w-2': 0, + 'flashlight-w-3': 0 + }) + + self._use('1-battery, flashlight', 'You are a Genius') + self._check_room_contents({ + 'battery': 2, + 'flashlight': 0, + 'flashlight-w-1': 1, + 'flashlight-w-2': 0, + 'flashlight-w-3': 0 + }) + + self._use('1-battery, flashlight-w-1', 'You are a Genius') + self._check_room_contents({ + 'battery': 1, + 'flashlight': 0, + 'flashlight-w-1': 0, + 'flashlight-w-2': 1, + 'flashlight-w-3': 0 + }) + + self._use('battery, flashlight-w-2', 'You are a Genius') + self._check_room_contents({ + 'battery': 0, + 'flashlight': 0, + 'flashlight-w-1': 0, + 'flashlight-w-2': 0, + 'flashlight-w-3': 1 + }) + + def test_e2e_interchangeable_parts_and_results(self): + # Parts and Results can be used in multiple puzzles + egg = create_object( + self.object_typeclass, + key='egg', location=self.char1.location) + flour = create_object( + self.object_typeclass, + key='flour', location=self.char1.location) + boiling_water = create_object( + self.object_typeclass, + key='boiling water', location=self.char1.location) + boiled_egg = create_object( + self.object_typeclass, + key='boiled egg', location=self.char1.location) + dough = create_object( + self.object_typeclass, + key='dough', location=self.char1.location) + pasta = create_object( + self.object_typeclass, + key='pasta', location=self.char1.location) + + # Three recipes: + # 1. breakfast: egg + boiling water = boiled egg & boiling water + # 2. dough: egg + flour = dough + # 3. entree: dough + boiling water = pasta & boiling water + # tag interchangeable parts according to their puzzles' name + egg.tags.add('breakfast', category=puzzles._PUZZLES_TAG_CATEGORY) + egg.tags.add('dough', category=puzzles._PUZZLES_TAG_CATEGORY) + dough.tags.add('entree', category=puzzles._PUZZLES_TAG_CATEGORY) + boiling_water.tags.add('breakfast', category=puzzles._PUZZLES_TAG_CATEGORY) + boiling_water.tags.add('entree', category=puzzles._PUZZLES_TAG_CATEGORY) + + # create recipes + recipe1_dbref = self._good_recipe('breakfast', ['egg', 'boiling water'], ['boiled egg', 'boiling water'] , and_destroy_it=False) + recipe2_dbref = self._good_recipe('dough', ['egg', 'flour'], ['dough'] , and_destroy_it=False, expected_count=2) + recipe3_dbref = self._good_recipe('entree', ['dough', 'boiling water'], ['pasta', 'boiling water'] , and_destroy_it=False, expected_count=3) + + # delete protoparts + for obj in [egg, flour, boiling_water, + boiled_egg, dough, pasta]: + obj.delete() + + # arm each puzzle and group its parts + def _group_parts(parts, excluding=set()): + group = dict() + dbrefs = dict() + for o in self.room1.contents: + if o.key in parts and o.dbref not in excluding: + if o.key not in group: + group[o.key] = [] + group[o.key].append(o.dbref) + dbrefs[o.dbref] = o + return group, dbrefs + + self._arm(recipe1_dbref, 'breakfast', ['egg', 'boiling water']) + breakfast_parts, breakfast_dbrefs = _group_parts(['egg', 'boiling water']) + self._arm(recipe2_dbref, 'dough', ['egg', 'flour']) + dough_parts, dough_dbrefs = _group_parts(['egg', 'flour'], excluding=breakfast_dbrefs.keys()) + self._arm(recipe3_dbref, 'entree', ['dough', 'boiling water']) + entree_parts, entree_dbrefs = _group_parts(['dough', 'boiling water'], excluding=set(breakfast_dbrefs.keys() + dough_dbrefs.keys())) + + # create a box so we can put all objects in + # so that they can't be found during puzzle resolution + self.box = create_object( + self.object_typeclass, + key='box', location=self.char1.location) + def _box_all(): + # print "boxing all\n", "-"*20 + for o in self.room1.contents: + if o not in [self.char1, self.char2, self.exit, + self.obj1, self.obj2, self.box]: + o.location = self.box + # print o.key, o.dbref, "boxed" + else: + # print "skipped", o.key, o.dbref + pass + + def _unbox(dbrefs): + # print "unboxing", dbrefs, "\n", "-"*20 + for o in self.box.contents: + if o.dbref in dbrefs: + o.location = self.room1 + # print "unboxed", o.key, o.dbref + + # solve dough puzzle using breakfast's egg + # and dough's flour. A new dough will be created + _box_all() + _unbox(breakfast_parts.pop('egg') + dough_parts.pop('flour')) + self._use('egg, flour', 'You are a Genius') + + # solve entree puzzle with newly created dough + # and breakfast's boiling water. A new + # boiling water and pasta will be created + _unbox(breakfast_parts.pop('boiling water')) + self._use('boiling water, dough', 'You are a Genius') + + # solve breakfast puzzle with dough's egg + # and newly created boiling water. A new + # boiling water and boiled egg will be created + _unbox(dough_parts.pop('egg')) + self._use('boiling water, egg', 'You are a Genius') + + # solve entree puzzle using entree's dough + # and newly created boiling water. A new + # boiling water and pasta will be created + _unbox(entree_parts.pop('dough')) + self._use('boiling water, dough', 'You are a Genius') + + self._check_room_contents({ + 'boiling water': 1, + 'pasta': 2, + 'boiled egg': 1, + }) + # Tests for the building_menu contrib from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch