diff --git a/evennia/contrib/puzzles.py b/evennia/contrib/puzzles.py new file mode 100644 index 0000000000..6523d13ea6 --- /dev/null +++ b/evennia/contrib/puzzles.py @@ -0,0 +1,472 @@ +""" +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 + + @puzzle smoothie puzzle, orange, mango, yogurt, blender = fruit smoothie + ... + Puzzle smoothie puzzle (#1234) created successfuly. + + @destroy/force orange, mango, yogurt, blender + + @armpuzzle #1234 + Part orange is spawned at ... + Part mango is spawned at ... + .... + Puzzle smoothie puzzle (#1234) has been armed successfully + +As Player: + + use orange, mango, yogurt, blender + ... + Genius, you blended all fruits to create a yummy smoothie! + +Details: + +Puzzles are created from existing objects. The given +objects are introspected to create prototypes for the +puzzle parts. These prototypes become the puzzle recipe. +(See PuzzleRecipeObject and @puzzle command). + +At a later time, a Builder or a Script can arm the puzzle +and spawn all puzzle parts (PuzzlePartObject) 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 django.conf import settings +from evennia import create_object +from evennia import CmdSet +from evennia import DefaultObject +from evennia import DefaultCharacter +from evennia import DefaultRoom +from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils.utils import inherits_from +from evennia.utils import search, utils, logger +from evennia.utils.spawner import spawn + +# ----------- UTILITY FUNCTIONS ------------ + +def proto_def(obj, with_tags=True): + """ + Basic properties needed to spawn + and compare recipe with candidate part + """ + protodef = { + 'key': obj.key, + 'typeclass': 'evennia.contrib.puzzles.PuzzlePartObject', # FIXME: what if obj is another typeclass + 'desc': obj.db.desc, + 'location': obj.location, + # FIXME: Can tags be INVISIBLE? We don't want player to know an object belongs to a puzzle + 'tags': [(_PUZZLES_TAG_MEMBER, _PUZZLES_TAG_CATEGORY)], + } + if not with_tags: + del(protodef['tags']) + return protodef + +# ------------------------------------------ + +# Tag used by puzzles +_PUZZLES_TAG_CATEGORY = 'puzzles' +_PUZZLES_TAG_RECIPE = 'puzzle_recipe' +# puzzle part and puzzle result +_PUZZLES_TAG_MEMBER = 'puzzle_member' + + +class PuzzlePartObject(DefaultObject): + """ + Puzzle Part, typically used by @armpuzzle command + """ + + def mark_as_puzzle_member(self, puzzle_name): + """ + Marks this object as a member of puzzle named + 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 + + +class PuzzleRecipeObject(DefaultObject): + """ + Definition of a Puzzle Recipe + """ + + def save_recipe(self, puzzle_name, parts, results): + self.db.puzzle_name = puzzle_name + self.db.parts = tuple(parts) + self.db.results = tuple(results) + self.tags.add(_PUZZLES_TAG_RECIPE, category=_PUZZLES_TAG_CATEGORY) + + +class CmdCreatePuzzleRecipe(MuxCommand): + """ + Creates a puzzle recipe. + + Each part and result must exist and be placed in their corresponding location. + All parts and results are left intact. Caller must explicitly + destroy them. + + Usage: + @puzzle name,] = + """ + + key = '@puzzle' + aliases = '@puzzlerecipe' + locks = 'cmd:perm(puzzle) or perm(Builder)' + help_category = 'Puzzles' + + 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] + + def is_valid_obj_location(obj): + valid = True + # Valid locations are: room, ... + # TODO: other valid locations must 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 + # TODO: handle contents of a given part + if not inherits_from(obj.location, settings.BASE_ROOM_TYPECLASS): + 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) + + parts = [] + for objname in self.lhslist[1:]: + obj = caller.search(objname) + if not obj: + return + if not is_valid_part_location(obj): + return + parts.append(obj) + + results = [] + for objname in self.rhslist: + obj = caller.search(objname) + if not obj: + return + if not is_valid_result_location(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_object(PuzzleRecipeObject, key=puzzle_name) + puzzle.save_recipe(puzzle_name, proto_parts, proto_results) + + 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' + 'You are now able to arm this puzzle using Builder command:\n' + ' @armpuzzle \n\n' + 'Or programmatically.\n' + ) + + # FIXME: puzzle recipe object exists but it has no location + # should we create a PuzzleLibrary where all puzzles are + # kept and cannot be reached by players? + + +class CmdArmPuzzle(MuxCommand): + """ + Arms a puzzle by spawning all its parts + """ + + key = '@armpuzzle' + # FIXME: permissions for scripts? + 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 = caller.search(self.args, global_search=True) + if not puzzle or not inherits_from(puzzle, PuzzleRecipeObject): + return + + 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: + # caller.msg('Protopart %r %r' % (proto_part, type(proto_part))) + 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.mark_as_puzzle_member(puzzle.db.puzzle_name) + + caller.msg("Puzzle armed |gsuccessfully|n.") + + +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 ] + """ + + # TODO: consider allowing builder to provide + # messages and "hooks" that can be displayed + # and/or fired whenever the resolver of the puzzle + # enters the location where a result was spawned + + 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) \ + or not inherits_from(part, PuzzlePartObject): + + # 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 dict + parts_dict = dict((part.dbref, part) for part in parts) + + # Group parts by their puzzle name + 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)) + ) + + # 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? + puzzles = [] + for puzzle_name, parts in puzzle_ingredients.items(): + _puzzles = caller.search( + puzzle_name, + typeclass=[PuzzleRecipeObject], + attribute_name='puzzle_name', + quiet=True, + exact=True, + global_search=True) + if not _puzzles: + continue + else: + puzzles.extend(_puzzles) + + # Create lookup dict + 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 = puzzle.db.parts[:] + parts = puzzle_ingredients[puzzle.db.puzzle_name][:] + 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) + else: + pass + p += 1 + else: + if len(puzzleparts) == len(matched_dbrefparts): + matched_puzzles[puzzle.dbref] = matched_dbrefparts + + if len(matched_puzzles) == 0: + # FIXME: Add more random messages + # random part falls and lands on your feet + # random part hits you square on the face + caller.msg("As you try to utilize %s, nothing happens." % (many)) + return + + puzzletuples = sorted(matched_puzzles.items(), key=lambda t: len(t[1]), reverse=True) + + # sort all matched puzzles and pick largest one(s) + puzzledbref, matched_dbrefparts = puzzletuples[0] + nparts = len(matched_dbrefparts) + largest_puzzles = list(itertools.takewhile(lambda t: len(t[1]) == nparts, puzzletuples)) + + # if there are more than one, let user pick + if len(largest_puzzles) > 1: + # FIXME: pick a random one or let user choose? + caller.msg( + 'Your gears start turning and a bunch of ideas come to your mind ...\n%s' % ( + ' ...\n'.join([lp.db.puzzle_name for lp in largest_puzzles])) + ) + puzzle = choice(largest_puzzles) + caller.msg("You try %s ..." % (puzzle.db.puzzle_name)) + + # got one, spawn its results + puzzle = puzzles_dict[puzzledbref] + # FIXME: DRY with parts + for proto_result in puzzle.db.results: + result = spawn(proto_result)[0] + result.mark_as_puzzle_member(puzzle.db.puzzle_name) + # FIXME: add 'ramdon' messages: + # Hmmm ... did I search result.location? + # What was that? ... I heard something in result.location? + # Eureka! you built a result + + # Destroy all parts used + for dbref in matched_dbrefparts: + parts_dict[dbref].delete() + + # FIXME: Add random messages + # You are a genius ... no matter what your 2nd grade teacher told you + # You hear thunders and a cloud of dust raises leaving + caller.msg("Puzzle solved |gsuccessfully|n.") + + +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 + # TODO: use @tags/search puzzle_recipe : puzzles + + +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 + # TODO: use @tags/search puzzle_member : puzzles + + +class PuzzleSystemCmdSet(CmdSet): + """ + CmdSet to create, arm and resolve Puzzles + + Add with @py self.cmdset.add("evennia.contrib.puzzles.PuzzlesCmdSet") + """ + + def at_cmdset_creation(self): + super(PuzzleSystemCmdSetCmdSet, self).at_cmdset_creation() + + self.add(CmdCreatePuzzleRecipe()) + self.add(CmdArmPuzzle()) + self.add(CmdUsePuzzleParts())