evennia/evennia/contrib/puzzles.py

623 lines
20 KiB
Python

"""
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 (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 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.utils.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!!!'
# ----------- UTILITY FUNCTIONS ------------
def proto_def(obj, with_tags=True):
"""
Basic properties needed to spawn
and compare recipe with candidate part
"""
protodef = {
# 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,
'home': obj.home,
'locks': ';'.join(obj.locks.all()),
'permissions': obj.permissions.all()[:],
}
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
_i = 0
_colors = ['|r', '|g', '|y']
_msg = []
for l in _PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE:
_msg += _colors[_i] + l
_i = (_i + 1) % len(_colors)
_PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE = ''.join(_msg) + '|n'
# ------------------------------------------
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'
"""
self.db.puzzle_name = puzzle_name
self.tags.add(puzzle_name, category=_PUZZLES_TAG_CATEGORY)
class PuzzleRecipe(DefaultScript):
"""
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)
self.db.use_success_message = _PUZZLE_DEFAULT_SUCCESS_USE_MESSAGE
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,<part1[,part2,...>] = <result1[,result2,...]>
"""
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,<part1[,...]> = <result1[,...]>"
caller.msg(string)
return
puzzle_name = self.lhslist[0]
if len(puzzle_name) == 0:
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, ...
# 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, 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)
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 = <Your custom success message>\n'
'You are now able to arm this puzzle using Builder command:\n'
' @armpuzzle <puzzle #dbref>\n'
)
class CmdEditPuzzle(MuxCommand):
"""
Edits puzzle properties
Usage:
@puzzleedit[/delete] <#dbref>
@puzzleedit <#dbref>/use_success_message = <Your custom message>
Switches:
delete - deletes the recipe. Existing parts and results aren't modified
"""
key = '@puzzleedit'
# FIXME: permissions for scripts?
locks = 'cmd:perm(puzzleedit) or perm(Builder)'
help_category = 'Puzzles'
def func(self):
_USAGE = "Usage: @puzzleedit[/switches] <dbref>[/attribute = <value>]"
caller = self.caller
if not self.lhslist:
caller.msg(_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" + _USAGE)
return
puzzle = search.search_script(recipe_dbref)
if not puzzle or not inherits_from(puzzle[0], PuzzleRecipe):
caller.msg('Invalid puzzle %r' % (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
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
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 = 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.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 <part1[,part2,...>]
"""
# 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 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
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 (i.e. tag name)
puzzles = []
for puzzle_name, parts in puzzlename_tags_dict.items():
_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)
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 = dict()
for puzzle in puzzles:
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(puzzle_protoparts) == 0:
matched_puzzles[puzzle.dbref] = matched_dbrefparts
if len(matched_puzzles) == 0:
# TODO: we could use part.fail_message instead, if any
# 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, ...
if len(largest_puzzles) > 1:
# FIXME: pick a random one or let user choose?
# FIXME: do we show the puzzle name or something else?
caller.msg(
'Your gears start turning and a bunch of ideas come to your mind ...\n%s' % (
' ...\n'.join([puzzles_dict[lp[0]].db.puzzle_name for lp in 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.mark_as_puzzle_member(puzzle.db.puzzle_name)
result_names.append(result.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()
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" % (
caller, 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 message:\n' + recipe.db.use_success_message + '\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 = ''
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))
text.append(div)
caller.msg('\n'.join(text))
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(PuzzleSystemCmdSet, self).at_cmdset_creation()
self.add(CmdCreatePuzzleRecipe())
self.add(CmdEditPuzzle())
self.add(CmdArmPuzzle())
self.add(CmdListPuzzleRecipes())
self.add(CmdListArmedPuzzles())
self.add(CmdUsePuzzleParts())