mirror of
https://github.com/evennia/evennia.git
synced 2026-04-02 14:07:16 +02:00
Puzzles System - first cut:
PuzzlePartObject: typeclass for puzzle parts and results. PuzzleRecipeObject: typeclass to store prototypes of parts and results. PuzzleSystemCmdSet: commands to create, arm and resolve puzzles.
This commit is contained in:
parent
a53957096f
commit
d0e632bb49
1 changed files with 472 additions and 0 deletions
472
evennia/contrib/puzzles.py
Normal file
472
evennia/contrib/puzzles.py
Normal file
|
|
@ -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,<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]
|
||||
|
||||
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 <puzzle #dbref>\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 <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 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue