From 1bdd7ce174bbeec66d8327e94fec80fdea2c9b51 Mon Sep 17 00:00:00 2001 From: Henddher Pedroza Date: Sun, 16 Sep 2018 17:25:57 -0500 Subject: [PATCH] Remove one-to-one part/result to puzzle relationship based on puzzle_name. Instead, Tags puzzle_name:_PUZZLES_TAG_CATEGORY are used for matching. This allows to use older PuzzlePartObjects in newly created puzzles by adding the new puzzles' puzzle_name:_PUZZLES_TAG_CATEGORY tag to the old objects. When creating proto parts and results, honor obj.home, obj.permissions, and obj.locks, and obj.tags --- evennia/contrib/puzzles.py | 90 ++++++++++++++++---------------- evennia/contrib/tests.py | 104 ++++++++++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 47 deletions(-) diff --git a/evennia/contrib/puzzles.py b/evennia/contrib/puzzles.py index 4f95062c5e..19a291358d 100644 --- a/evennia/contrib/puzzles.py +++ b/evennia/contrib/puzzles.py @@ -98,15 +98,19 @@ def proto_def(obj, with_tags=True): and compare recipe with candidate part """ protodef = { - # FIXME: Don't we need to honor ALL properties? locks, perms, etc. + # FIXME: Don't we need to honor ALL properties? attributes, contents, etc. 'key': obj.key, 'typeclass': 'evennia.contrib.puzzles.PuzzlePartObject', # FIXME: what if obj is another typeclass 'desc': obj.db.desc, 'location': obj.location, - 'tags': [(_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY)], + 'home': obj.home, + 'locks': ';'.join(obj.locks.all()), + 'permissions': obj.permissions.all()[:], } - if not with_tags: - del(protodef['tags']) + if with_tags: + tags = obj.tags.all()[:] + tags.append((_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY)) + protodef['tags'] = tags return protodef # Colorize the default success message @@ -128,16 +132,10 @@ class PuzzlePartObject(DefaultObject): def mark_as_puzzle_member(self, puzzle_name): """ Marks this object as a member of puzzle named - puzzle_name + 'puzzle_name' """ - # FIXME: if multiple puzzles have the same - # puzzle_name, their ingredients may be - # combined but leave other parts orphan - # Similarly, if a puzzle_name were changed, - # its parts will become orphan - # Perhaps we should use #dbref but that will - # force specific parts to be combined self.db.puzzle_name = puzzle_name + self.tags.add(puzzle_name, category=_PUZZLES_TAG_CATEGORY) class PuzzleRecipe(DefaultScript): @@ -184,6 +182,10 @@ class CmdCreatePuzzleRecipe(MuxCommand): caller.msg('Invalid puzzle name %r.' % puzzle_name) return + # TODO: if there is another puzzle with same name + # warn user that parts and results will be + # interchangable + def is_valid_obj_location(obj): valid = True # Valid locations are: room, ... @@ -422,24 +424,24 @@ class CmdUsePuzzleParts(MuxCommand): # a valid part parts.append(part) - # Create lookup dict - parts_dict = dict((part.dbref, part) for part in parts) - - # Group parts by their puzzle name + # 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: - puzzle_name = part.db.puzzle_name - if puzzle_name not in puzzle_ingredients: - puzzle_ingredients[puzzle_name] = [] - puzzle_ingredients[puzzle_name].append( - (part.dbref, proto_def(part, with_tags=False)) - ) + parts_dict[part.dbref] = part + puzzle_ingredients[part.dbref] = proto_def(part, with_tags=False) + 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) - - # Find all puzzles by puzzle name - # FIXME: we rely on obj.db.puzzle_name which is visible and may be cnaged afterwards. Can we lock it and hide it? + # Find all puzzles by puzzle name (i.e. tag name) puzzles = [] - for puzzle_name, parts in puzzle_ingredients.items(): + for puzzle_name, parts in puzzlename_tags_dict.items(): _puzzles = search.search_script_attribute( key='puzzle_name', value=puzzle_name @@ -450,30 +452,26 @@ class CmdUsePuzzleParts(MuxCommand): else: puzzles.extend(_puzzles) - logger.log_info("PUZZLES %r" % ([p.dbref for p in puzzles])) + logger.log_info("PUZZLES %r" % ([(p.dbref, p.db.puzzle_name) for p in puzzles])) - # Create lookup dict + # 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 = dict() for puzzle in puzzles: - puzzleparts = list(sorted(puzzle.db.parts[:], key=lambda p: p['key'])) - parts = list(sorted(puzzle_ingredients[puzzle.db.puzzle_name][:], key=lambda p: p[1]['key'])) - pz = 0 - p = 0 - matched_dbrefparts = set() - while pz < len(puzzleparts) and p < len(parts): - puzzlepart = puzzleparts[pz] - if 'tags' in puzzlepart: - # remove 'tags' as they will prevent equality - del(puzzlepart['tags']) - dbref, part = parts[p] - if part == puzzlepart: - pz += 1 - matched_dbrefparts.add(dbref) - p += 1 + puzzle_protoparts = list(puzzle.db.parts[:]) + # remove tags as they prevent equality + for puzzle_protopart in puzzle_protoparts: + del(puzzle_protopart['tags']) + matched_dbrefparts = [] + parts_dbrefs = puzzlename_tags_dict[puzzle.db.puzzle_name] + for part_dbref in parts_dbrefs: + protopart = puzzle_ingredients[part_dbref] + if protopart in puzzle_protoparts: + puzzle_protoparts.remove(protopart) + matched_dbrefparts.append(part_dbref) else: - if len(puzzleparts) == len(matched_dbrefparts): + if len(puzzle_protoparts) == 0: matched_puzzles[puzzle.dbref] = matched_dbrefparts if len(matched_puzzles) == 0: @@ -506,7 +504,6 @@ class CmdUsePuzzleParts(MuxCommand): caller.msg("You try %s ..." % (puzzle.db.puzzle_name)) # got one, spawn its results - # FIXME: DRY with parts result_names = [] for proto_result in puzzle.db.results: result = spawn(proto_result)[0] @@ -523,6 +520,7 @@ class CmdUsePuzzleParts(MuxCommand): result_names = ', '.join(result_names) caller.msg(puzzle.db.use_success_message) + # TODO: allow custom message for location and channels caller.location.msg_contents( "|c%s|n performs some kind of tribal dance" " and |y%s|n seems to appear from thin air" % ( @@ -554,7 +552,7 @@ class CmdListPuzzleRecipes(MuxCommand): 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 message: ' + recipe.db.use_success_message) + text.append('Success message:\n' + recipe.db.use_success_message + '\n') text.append('Parts') for protopart in recipe.db.parts[:]: mark = '-' diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index efd2a3cdb0..8620a28326 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1487,7 +1487,7 @@ class TestPuzzles(CommandTest): [ r"^-+$", r"^Puzzle 'makefire'.*$", - r"^Success message: .*$", + r"^Success message:$", r"^Parts$", r"^.*key: stone$", r"^.*key: flint$", @@ -1609,3 +1609,105 @@ class TestPuzzles(CommandTest): # TODO: results has NPC # TODO: results has Room + + # TODO: parts' location can be different from Character's location + + def test_e2e_interchangeable_parts_and_results(self): + # Parts and Results can be used in multiple puzzles + egg = create_object(key='egg', location=self.char1.location) + flour = create_object(key='flour', location=self.char1.location) + boiling_water = create_object(key='boiling water', location=self.char1.location) + boiled_egg = create_object(key='boiled egg', location=self.char1.location) + dough = create_object(key='dough', location=self.char1.location) + pasta = create_object(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(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, + })