Add the full EvscapeRoom game engine as a contrib

This commit is contained in:
Griatch 2019-06-18 19:38:53 +02:00
parent 6c02b68278
commit a00cc681d9
13 changed files with 3509 additions and 0 deletions

View file

@ -163,6 +163,8 @@
### Contribs
- Evscaperoom - a full puzzle engine for making multiplayer escape rooms in Evennia. Used to make
the entry for the MUD-Coder's Guild's 2019 Game Jam with the theme "One Room", where it ranked #1.
- The `extended_room` contrib saw some backwards-incompatible refactoring:
+ All commands now begin with `CmdExtendedRoom`. So before it was `CmdExtendedLook`, now
it's `CmdExtendedRoomLook` etc.

View file

@ -29,6 +29,7 @@ things you want from here into your game folder and change them there.
* Dice (Griatch 2012) - A fully featured dice rolling system.
* Email-login (Griatch 2012) - A variant of the standard login system
that requires an email to login rather then just name+password.
* Evscaperoom (Griatch 2019) - A full engine for making escaperoom puzzles
* Extended Room (Griatch 2012) - An expanded Room typeclass with
multiple descriptions for time and season as well as details.
* Field Fill (FlutterSprite 2018) - A simple system for creating an

View file

@ -0,0 +1,116 @@
# EvscapeRoom
Evennia contrib - Griatch 2019
This 'Evennia escaperoom game engine' was created for the MUD Coders Guild game
Jam, April 14-May 15 2019. The theme for the jam was "One Room". This contains the
utilities and base classes and an empty example room. The code for the full
in-production game 'Evscaperoom' is found at https://github.com/Griatch/evscaperoom
and you can play the full game (for now) at `http://experimental.evennia.com`.
# Introduction
Evscaperoom is, as it sounds, an escaperoom in text form. You start locked into
a room and have to figure out how to get out. This engine contains everything needed
to make a fully-featured puzzle game of this type!
# Installation
The Evscaperoom is installed by adding the `evscaperoom` command to your
character cmdset. When you run that command in-game you're ready to play!
In `mygame/commands/default_cmdsets.py`:
```python
from evennia.contrib.evscaperoom.commands import CmdEvscapeRoomStart
class CharacterCmdSet(...):
# ...
self.add(CmdEvscapeRoomStart())
```
Reload the server and the `evscaperoom` command will be available. The contrib comes
with a small (very small) escape room as an example.
# Making your own evscaperoom
To do this, you need to make your own states. First make sure you can play the
simple example room installed above.
Copy `evennia/contrib/evscaperoom/states` to somewhere in your game folder (let's
assume you put it under `mygame/world/`).
Next you need to re-point Evennia to look for states in this new location. Add
the following to your `mygame/server/conf/settings.py` file:
```python
EVSCAPEROOM_STATE_PACKAGE = "world.states"
```
Reload and the example evscaperoom should still work, but you can now modify and expand
it from your game dir!
## Other useful settings
There are a few other settings that may be useful:
- `EVSCAPEROOM_START_STATE` - default is `state_001_start` and is the name of the
state-module to start from (without `.py`). You can change this if you want some
other naming scheme.
- `HELP_SUMMARY_TEXT` - this is the help blurb shown when entering `help` in
the room without an argument. The original is found at the top of
`evennia/contrib/evscaperoom/commands.py`.
# Playing the game
You should start by `look`ing around and at objects.
The `examine <object>` command allows you to 'focus' on an object. When you do
you'll learn actions you could try for the object you are focusing on, such as
turning it around, read text on it or use it with some other object. Note that
more than one player can focus on the same object, so you won't block anyone
when you focus. Focusing on another object or use `examine` again will remove
focus.
There is also a full hint system.
# Technical
When connecting to the game, the user has the option to join an existing room
(which may already be in some state of ongoing progress), or may create a fresh
room for them to start solving on their own (but anyone may still join them later).
The room will go through a series of 'states' as the players progress through
its challenges. These states are describes as modules in .states/ and the
room will load and execute the State-object within each module to set up
and transition between states as the players progress. This allows for isolating
the states from each other and will hopefully make it easier to track
the logic and (in principle) inject new puzzles later.
Once no players remain in the room, the room and its state will be wiped.
# Design Philosophy
Some basic premises inspired the design of this.
- You should be able to resolve the room alone. So no puzzles should require the
collaboration of multiple players. This is simply because there is no telling
if others will actually be online at a given time (or stay online throughout).
- You should never be held up by the actions/inactions of other players. This
is why you cannot pick up anything (no inventory system) but only
focus/operate on items. This avoids the annoying case of a player picking up
a critical piece of a puzzle and then logging off.
- A room's state changes for everyone at once. My first idea was to have a given
room have different states depending on who looked (so a chest could be open
and closed to two different players at the same time). But not only does this
add a lot of extra complexity, it also defeats the purpose of having multiple
players. This way people can help each other and collaborate like in a 'real'
escape room. For people that want to do it all themselves I instead made it
easy to start "fresh" rooms for them to take on.
All other design decisions flowed from these.

View file

@ -0,0 +1,752 @@
"""
Commands for the Evscaperoom. This contains all in-room commands as well as
admin debug-commands to help development.
Gameplay commands
- `look` - custom look
- `focus` - focus on object (to perform actions on it)
- `<action> <obj>` - arbitrary interaction with focused object
- `stand` - stand on the floor, resetting any position
- `emote` - free-form emote
- `say/whisper/shout` - simple communication
Other commands
- `evscaperoom` - starts the evscaperoom top-level menu
- `help` - custom in-room help command
- `options` - set game/accessibility options
- `who` - show who's in the room with you
- `quit` - leave a room, return to menu
Admin/development commands
- `jumpstate` - jump to specific room state
- `flag` - assign a flag to an object
- `createobj` - create a room-object set up for Evscaperoom
"""
import re
from django.conf import settings
from evennia import SESSION_HANDLER
from evennia import Command, CmdSet, InterruptCommand, default_cmds
from evennia import syscmdkeys
from evennia.utils import variable_from_module
from .utils import create_evscaperoom_object
_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1))
_RE_ARGSPLIT = re.compile(r"\s(with|on|to|in|at)\s", re.I + re.U)
_RE_EMOTE_SPEECH = re.compile(r"(\".*?\")|(\'.*?\')")
_RE_EMOTE_NAME = re.compile(r"(/\w+)")
_RE_EMOTE_PROPER_END = re.compile(r"\.$|\.[\'\"]$|\![\'\"]$|\?[\'\"]$")
# configurable help
if hasattr(settings, "EVSCAPEROOM_HELP_SUMMARY_TEXT"):
_HELP_SUMMARY_TEXT = settings.EVSCAPEROOM_HELP_SUMMARY_TEXT
else:
_HELP_SUMMARY_TEXT = """
|yWhat to do ...|n
- Your goal is to |wescape|n the room. To do that you need to |wlook|n at
your surroundings for clues on how to escape. When you find something
interesting, |wexamine|n it for any actions you could take with it.
|yHow to explore ...|n
- |whelp [obj or command]|n - get usage help (never puzzle-related)
- |woptions|n - set game/accessibility options
- |wlook/l [obj]|n - give things a cursory look.
- |wexamine/ex/e [obj]|n - look closer at an object. Use again to
look away.
- |wstand|n - stand up if you were sitting, lying etc.
|yHow to express yourself ...|n
- |wwho [all]|n - show who's in the room or on server.
- |wemote/pose/: <something>|n - free-form emote. Use /me to refer
to yourself and /name to refer to other
things/players. Use quotes "..." to speak.
- |wsay/; <something>|n - quick-speak your mind
- |wwhisper <something>|n - whisper your mind
- |wshout <something>|n - shout your mind
|yHow to quit like a little baby ...|n
- |wquit / give up|n - admit defeat and give up
"""
_HELP_FALLBACK_TEXT = """
There is no help to be had about |y{this}|n. To look away, use |wexamine|n on
its own or with another object you are interested in.
"""
_QUIT_WARNING = """
|rDo you really want to quit?|n
{warning}
Enter |w'quit'|n again to truly give up.
"""
_QUIT_WARNING_CAN_COME_BACK = """
(Since you are not the last person to leave this room, you |gcan|n get back in here
by joining room '|c{roomname}|n' from the menu. Note however that if you leave
now, any personal achievements you may have gathered so far will be |rlost|n!)
"""
_QUIT_WARNING_LAST_CHAR = """
(You are the |rlast|n player to leave this room ('|c{roomname}|n'). This means that when you
leave, this room will go away and you |rwon't|n be able to come back to it!)
"""
class CmdEvscapeRoom(Command):
"""
Base command parent for all Evscaperoom commands.
This operates on the premise of 'focus' - the 'focus'
is set on the caller, then subsequent commands will
operate on that focus. If no focus is set,
the operation will be general or on the room.
Syntax:
command [<obj1>|<arg1>] [<prep> <obj2>|<arg2>]
"""
# always separate the command from any args with a space
arg_regex = r"(/\w+?(\s|$))|\s|$"
help_category = "Evscaperoom"
# these flags allow child classes to determine how strict parsing for obj1/obj2 should be
# (if they are given at all):
# True - obj1/obj2 must be found as Objects, otherwise it's an error aborting command
# False - obj1/obj2 will remain None, instead self.arg1, arg2 will be stored as strings
# None - if obj1/obj2 are found as Objects, set them, otherwise set arg1/arg2 as strings
obj1_search = None
obj2_search = None
def _search(self, query, required):
"""
This implements the various search modes
Args:
query (str): The search query
required (bool or None): This defines if query *must* be
found to match a single local Object or not. If None,
a non-match means returning the query unchanged. When
False, immediately return the query. If required is False,
don't search at all.
Return:
match (Object or str): The match or the search string depending
on the `required` mode.
Raises:
InterruptCommand: Aborts the command quietly.
Notes:
The _AT_SEARCH_RESULT function will handle all error messaging
for us.
"""
if required is False:
return None, query
matches = self.caller.search(query, quiet=True)
if not matches or len(matches) > 1:
if required:
if not query:
self.caller.msg("You must give an argument.")
else:
_AT_SEARCH_RESULT(matches, self.caller, query=query)
raise InterruptCommand
else:
return None, query
else:
return matches[0], None
def parse(self):
"""
Parse incoming arguments for use in all child classes.
"""
caller = self.caller
self.args = self.args.strip()
# splits to either ['obj'] or e.g. ['obj', 'on', 'obj']
parts = [part.strip() for part in _RE_ARGSPLIT.split(" " + self.args, 1)]
nparts = len(parts)
self.obj1 = None
self.arg1 = None
self.prep = None
self.obj2 = None
self.arg2 = None
if nparts == 1:
self.obj1, self.arg1 = self._search(parts[0], self.obj1_search)
elif nparts == 3:
obj1, self.prep, obj2 = parts
self.obj1, self.arg1 = self._search(obj1, self.obj1_search)
self.obj2, self.arg2 = self._search(obj2, self.obj2_search)
self.room = caller.location
self.roomstate = self.room.db.state
@property
def focus(self):
return self.caller.attributes.get("focus", category=self.room.db.tagcategory)
@focus.setter
def focus(self, obj):
self.caller.attributes.add("focus", obj, category=self.room.tagcategory)
@focus.deleter
def focus(self):
self.caller.attributes.remove("focus", category=self.room.tagcategory)
class CmdGiveUp(CmdEvscapeRoom):
"""
Give up
Usage:
give up
Abandons your attempts at escaping and of ever winning the pie-eating contest.
"""
key = "give up"
aliases = ("abort", "chicken out", "quit", "q")
def func(self):
from .menu import run_evscaperoom_menu
nchars = len(self.room.get_all_characters())
if nchars == 1:
warning = _QUIT_WARNING_LAST_CHAR.format(roomname=self.room.name)
warning = _QUIT_WARNING.format(warning=warning)
else:
warning = _QUIT_WARNING_CAN_COME_BACK.format(roomname=self.room.name)
warning = _QUIT_WARNING.format(warning=warning)
ret = yield(warning)
if ret.upper() == "QUIT":
self.msg("|R ... Oh. Okay then. Off you go.|n\n")
yield(1)
self.room.log(f"QUIT: {self.caller.key} used the quit command")
# manually call move hooks
self.room.msg_room(self.caller, f"|r{self.caller.key} gave up and was whisked away!|n")
self.room.at_object_leave(self.caller, self.caller.home)
self.caller.move_to(self.caller.home, quiet=True, move_hooks=False)
# back to menu
run_evscaperoom_menu(self.caller)
else:
self.msg("|gYou're staying? That's the spirit!|n")
class CmdLook(CmdEvscapeRoom):
"""
Look at the room, an object or the currently focused object
Usage:
look [obj]
"""
key = "look"
aliases = ["l", "ls"]
obj1_search = None
obj2_search = None
def func(self):
caller = self.caller
target = self.obj1 or self.obj2 or self.focus or self.room
# the at_look hook will in turn call return_appearance and
# pass the 'unfocused' kwarg to it
txt = caller.at_look(target, unfocused=(target and target != self.focus))
self.room.msg_char(caller, txt, client_type="look")
class CmdWho(CmdEvscapeRoom, default_cmds.CmdWho):
"""
List other players in the game.
Usage:
who
who all
Show who is in the room with you, or (with who all), who is online on the
server as a whole.
"""
key = "who"
obj1_search = False
obj2_search = False
def func(self):
caller = self.caller
if self.args == 'all':
table = self.style_table("|wName", "|wRoom")
sessions = SESSION_HANDLER.get_sessions()
for session in sessions:
puppet = session.get_puppet()
if puppet:
location = puppet.location
locname = location.key if location else "(Outside somewhere)"
table.add_row(puppet, locname)
else:
account = session.get_account()
table.add_row(account.get_display_name(caller), "(OOC)")
txt = (f"|cPlayers active on this server|n:\n{table}\n"
"(use 'who' to see only those in your room)")
else:
chars = [f"{obj.get_display_name(caller)} - {obj.db.desc.strip()}"
for obj in self.room.get_all_characters()
if obj != caller]
chars = "\n".join([f"{caller.key} - {caller.db.desc.strip()} (you)"] + chars)
txt = (f"|cPlayers in this room (room-name '{self.room.name}')|n:\n {chars}")
caller.msg(txt)
class CmdSpeak(Command):
"""
Perform an communication action.
Usage:
say <text>
whisper
shout
"""
key = "say"
aliases = [";", "shout", "whisper"]
arg_regex = r"\w|\s|$"
def func(self):
args = self.args.strip()
caller = self.caller
action = self.cmdname
action = "say" if action == ';' else action
room = self.caller.location
if not self.args:
caller.msg(f"What do you want to {action}?")
return
if action == "shout":
args = f"|c{args.upper()}|n"
elif action == "whisper":
args = f"|C({args})|n"
else:
args = f"|c{args}|n"
message = f"~You ~{action}: {args}"
if hasattr(room, "msg_room"):
room.msg_room(caller, message)
room.log(f"{action} by {caller.key}: {args}")
class CmdEmote(Command):
"""
Perform a free-form emote. Use /me to
include yourself in the emote and /name
to include other objects or characters.
Use "..." to enact speech.
Usage:
emote <emote>
:<emote
Example:
emote /me smiles at /peter
emote /me points to /box and /lever.
"""
key = "emote"
aliases = [":", "pose"]
arg_regex = r"\w|\s|$"
def you_replace(match):
return match
def room_replace(match):
return match
def func(self):
emote = self.args.strip()
if not emote:
self.caller.msg("Usage: emote /me points to /door, saying \"look over there!\"")
return
speech_clr = "|c"
obj_clr = "|y"
self_clr = "|g"
player_clr = "|b"
add_period = not _RE_EMOTE_PROPER_END.search(emote)
emote = _RE_EMOTE_SPEECH.sub(speech_clr + r"\1\2|n", emote)
room = self.caller.location
characters = room.get_all_characters()
logged = False
for target in characters:
txt = []
self_refer = False
for part in _RE_EMOTE_NAME.split(emote):
nameobj = None
if part.startswith("/"):
name = part[1:]
if name == "me":
nameobj = self.caller
self_refer = True
else:
match = self.caller.search(name, quiet=True)
if len(match) == 1:
nameobj = match[0]
if nameobj:
if target == nameobj:
part = f"{self_clr}{nameobj.get_display_name(target)}|n"
elif nameobj in characters:
part = f"{player_clr}{nameobj.get_display_name(target)}|n"
else:
part = f"{obj_clr}{nameobj.get_display_name(target)}|n"
txt.append(part)
if not self_refer:
if target == self.caller:
txt = [f"{self_clr}{self.caller.get_display_name(target)}|n "] + txt
else:
txt = [f"{player_clr}{self.caller.get_display_name(target)}|n "] + txt
txt = "".join(txt).strip() + ("." if add_period else "")
if not logged and hasattr(self.caller.location, "log"):
self.caller.location.log(f"emote: {txt}")
logged = True
target.msg(txt)
class CmdFocus(CmdEvscapeRoom):
"""
Focus your attention on a target.
Usage:
focus <obj>
Once focusing on an object, use look to get more information about how it
looks and what actions is available.
"""
key = "focus"
aliases = ["examine", "e", "ex", "unfocus"]
obj1_search = None
def func(self):
if self.obj1:
old_focus = self.focus
if hasattr(old_focus, "at_unfocus"):
old_focus.at_unfocus(self.caller)
if not hasattr(self.obj1, "at_focus"):
self.caller.msg("Nothing of interest there.")
return
if self.focus != self.obj1:
self.room.msg_room(self.caller, f"~You ~examine *{self.obj1.key}.", skip_caller=True)
self.focus = self.obj1
self.obj1.at_focus(self.caller)
elif not self.focus:
self.caller.msg("What do you want to focus on?")
else:
old_focus = self.focus
del self.focus
self.caller.msg(f"You no longer focus on |y{old_focus.key}|n.")
class CmdOptions(CmdEvscapeRoom):
"""
Start option menu
Usage:
options
"""
key = "options"
aliases = ["option"]
def func(self):
from .menu import run_option_menu
run_option_menu(self.caller, self.session)
class CmdGet(CmdEvscapeRoom):
"""
Use focus / examine instead.
"""
key = "get"
aliases = ["inventory", "i", "inv", "give"]
def func(self):
self.caller.msg("Use |wfocus|n or |wexamine|n for handling objects.")
class CmdRerouter(default_cmds.MuxCommand):
"""
Interact with an object in focus.
Usage:
<action> [arg]
"""
# reroute commands from the default cmdset to the catch-all
# focus function where needed. This allows us to override
# individual default commands without replacing the entire
# cmdset (we want to keep most of them).
key = "open"
aliases = ["@dig", "@open"]
def func(self):
# reroute to another command
from evennia.commands import cmdhandler
cmdhandler.cmdhandler(self.session, self.raw_string,
cmdobj=CmdFocusInteraction(),
cmdobj_key=self.cmdname)
class CmdFocusInteraction(CmdEvscapeRoom):
"""
Interact with an object in focus.
Usage:
<action> [arg]
This is a special catch-all command which will operate on
the current focus. It will look for a method
`focused_object.at_focus_<action>(caller, **kwargs)` and call
it. This allows objects to just add a new hook to make that
action apply to it. The obj1, prep, obj2, arg1, arg2 are passed
as keys into the method.
"""
# all commands not matching something else goes here.
key = syscmdkeys.CMD_NOMATCH
obj1_search = None
obj2_search = None
def parse(self):
"""
We assume this type of command is always on the form `command [arg]`
"""
self.args = self.args.strip()
parts = self.args.split(None, 1)
if not self.args:
self.action, self.args = "", ""
elif len(parts) == 1:
self.action = parts[0]
self.args = ""
else:
self.action, self.args = parts
self.room = self.caller.location
def func(self):
focused = self.focus
action = self.action
if focused and hasattr(focused, f"at_focus_{action}"):
# there is a suitable hook to call!
getattr(focused, f"at_focus_{action}")(self.caller, args=self.args)
else:
self.caller.msg("Hm?")
class CmdStand(CmdEvscapeRoom):
"""
Stand up from whatever position you had.
"""
key = "stand"
def func(self):
# Positionable objects will set this flag on you.
pos = self.caller.attributes.get(
"position", category=self.room.tagcategory)
if pos:
# we have a position, clean up.
obj, position = pos
self.caller.attributes.remove(
"position", category=self.room.tagcategory)
del obj.db.positions[self.caller]
self.room.msg_room(self.caller, "~You ~are back standing on the floor again.")
else:
self.caller.msg("You are already standing.")
class CmdHelp(CmdEvscapeRoom, default_cmds.CmdHelp):
"""
Get help.
Usage:
help <topic> or <command>
"""
key = 'help'
aliases = ['?']
def func(self):
if self.obj1:
if hasattr(self.obj1, "get_help"):
helptxt = self.obj1.get_help(self.caller)
if not helptxt:
helptxt = f"There is no help to be had about {self.obj1.get_display_name(self.caller)}."
else:
helptxt = (f"|y{self.obj1.get_display_name(self.caller)}|n is "
"likely |rnot|n part of any of the Jester's trickery.")
elif self.arg1:
# fall back to the normal help command
super().func()
return
else:
helptxt = _HELP_SUMMARY_TEXT
self.caller.msg(helptxt.rstrip())
# Debug/help command
class CmdCreateObj(CmdEvscapeRoom):
"""
Create command, only for Admins during debugging.
Usage:
createobj name[:typeclass]
Here, :typeclass is a class in evscaperoom.commands
"""
key = "createobj"
aliases = ["cobj"]
locks = "cmd:perm(Admin)"
obj1_search = False
obj2_search = False
def func(self):
caller = self.caller
args = self.args
if not args:
caller.msg("Usage: createobj name[:typeclass]")
return
typeclass = "EvscaperoomObject"
if ":" in args:
name, typeclass = (part.strip() for part in args.rsplit(":", 1))
if typeclass.startswith("state_"):
# a state class
typeclass = "evscaperoom.states." + typeclass
else:
name = args.strip()
obj = create_evscaperoom_object(typeclass=typeclass, key=name, location=self.room)
caller.msg(f"Created new object {name} ({obj.typeclass_path}).")
class CmdSetFlag(CmdEvscapeRoom):
"""
Assign a flag to an object. Admin use only
Usage:
flag <obj> with <flagname>
"""
key = "flag"
aliases = ["setflag"]
locks = "cmd:perm(Admin)"
obj1_search = True
obj2_search = False
def func(self):
if not self.arg2:
self.caller.msg("Usage: flag <obj> with <flagname>")
return
if hasattr(self.obj1, "set_flag"):
if self.obj1.check_flag(self.arg2):
self.obj1.unset_flag(self.arg2)
self.caller.msg(f"|rUnset|n flag '{self.arg2}' on {self.obj1}.")
else:
self.obj1.set_flag(self.arg2)
self.caller.msg(f"|gSet|n flag '{self.arg2}' on {self.obj1}.")
else:
self.caller.msg(f"Cannot set flag on {self.obj1}.")
class CmdJumpState(CmdEvscapeRoom):
"""
Jump to a given state.
Args:
jumpstate <statename>
"""
key = "jumpstate"
locks = "cmd:perm(Admin)"
obj1_search = False
obj2_search = False
def func(self):
self.caller.msg(f"Trying to move to state {self.args}")
self.room.next_state(self.args)
# Helper command to start the Evscaperoom menu
class CmdEvscapeRoomStart(Command):
"""
Go to the Evscaperoom start menu
"""
key = "evscaperoom"
help_category = "EvscapeRoom"
def func(self):
# need to import here to break circular import
from .menu import run_evscaperoom_menu
run_evscaperoom_menu(self.caller)
# command sets
class CmdSetEvScapeRoom(CmdSet):
priority = 1
def at_cmdset_creation(self):
self.add(CmdHelp())
self.add(CmdLook())
self.add(CmdGiveUp())
self.add(CmdFocus())
self.add(CmdSpeak())
self.add(CmdEmote())
self.add(CmdFocusInteraction())
self.add(CmdStand())
self.add(CmdWho())
self.add(CmdOptions())
# rerouters
self.add(CmdGet())
self.add(CmdRerouter())
# admin commands
self.add(CmdCreateObj())
self.add(CmdSetFlag())
self.add(CmdJumpState())

View file

@ -0,0 +1,325 @@
"""
Start menu
This is started from the `evscaperoom` command.
Here player user can set their own description as well as select to create a
new room (to start from scratch) or join an existing room (with other players).
"""
from evennia import EvMenu
from evennia.utils.evmenu import list_node
from evennia.utils import create, justify, list_to_string
from evennia.utils import logger
from .room import EvscapeRoom
from .utils import create_fantasy_word
# ------------------------------------------------------------
# Main menu
# ------------------------------------------------------------
_START_TEXT = """
|mEv|rScape|mRoom|n
|x- an escape-room experience using Evennia|n
You are |c{name}|n - {desc}|n.
Make a selection below.
"""
_CREATE_ROOM_TEXT = """
This will create a |ynew, empty room|n to challenge you.
Other players can be thrown in there at any time.
Remember that if you give up and are the last person to leave, that particular
room will be gone!
|yDo you want to create (and automatically join) a new room?|n")
"""
_JOIN_EXISTING_ROOM_TEXT = """
This will have you join an existing room ({roomname}).
This is {percent}% complete and has {nplayers} player(s) in it already:
{players}
|yDo you want to join this room?|n
"""
def _move_to_room(caller, raw_string, **kwargs):
"""
Helper to move a user to a room
"""
room = kwargs['room']
room.msg_char(caller, f"Entering room |c'{room.name}'|n ...")
room.msg_room(caller, f"~You |c~were just tricked in here too!|n")
# we do a manual move since we don't want all hooks to fire.
old_location = caller.location
caller.location = room
room.at_object_receive(caller, old_location)
return "node_quit", {"quiet": True}
def _create_new_room(caller, raw_string, **kwargs):
# create a random name, retrying until we find
# a unique one
key = create_fantasy_word(length=5, capitalize=True)
while EvscapeRoom.objects.filter(db_key=key):
key = create_fantasy_word(length=5, capitalize=True)
room = create.create_object(EvscapeRoom, key=key)
# we must do this once manually for the new room
room.statehandler.init_state()
_move_to_room(caller, "", room=room)
nrooms = EvscapeRoom.objects.all().count()
logger.log_info(f"Evscaperoom: {caller.key} created room '{key}' (#{room.id}). Now {nrooms} room(s) active.")
room.log(f"JOIN: {caller.key} created and joined room")
return "node_quit", {"quiet": True}
def _get_all_rooms(caller):
"""
Get a list of all available rooms and store the mapping
between option and room so we get to it later.
"""
room_option_descs = []
room_map = {}
for room in EvscapeRoom.objects.all():
if not room.pk or room.db.deleting:
continue
stats = room.db.stats or {"progress": 0}
progress = int(stats['progress'])
nplayers = len(room.get_all_characters())
desc = (f"Join room |c'{room.get_display_name(caller)}'|n "
f"(complete: {progress}%, players: {nplayers})")
room_map[desc] = room
room_option_descs.append(desc)
caller.ndb._menutree.room_map = room_map
return room_option_descs
def _select_room(caller, menuchoice, **kwargs):
"""
Get a room from the selection using the mapping we created earlier.
"""
room = caller.ndb._menutree.room_map[menuchoice]
return "node_join_room", {"room": room}
@list_node(_get_all_rooms, _select_room)
def node_start(caller, raw_string, **kwargs):
text = _START_TEXT.strip()
text = text.format(name=caller.key, desc=caller.db.desc)
# build a list of available rooms
options = (
{"key": ("|y[s]et your description|n", "set your description",
"set", "desc", "description", "s"),
"goto": "node_set_desc"},
{"key": ("|y[c]reate/join a new room|n", "create a new room", "create", "c"),
"goto": "node_create_room"},
{"key": ("|r[q]uit the challenge", "quit", "q"),
"goto": "node_quit"})
return text, options
def node_set_desc(caller, raw_string, **kwargs):
current_desc = kwargs.get('desc', caller.db.desc)
text = ("Your current description is\n\n "
f" \"{current_desc}\""
"\n\nEnter your new description!")
def _temp_description(caller, raw_string, **kwargs):
desc = raw_string.strip()
if 5 < len(desc) < 40:
return None, {"desc": raw_string.strip()}
else:
caller.msg("|rYour description must be 5-40 characters long.|n")
return None
def _set_description(caller, raw_string, **kwargs):
caller.db.desc = kwargs.get("desc")
caller.msg("You set your description!")
return "node_start"
options = (
{"key": "_default",
"goto": _temp_description},
{"key": ("|g[a]ccept", "a"),
"goto": (_set_description, {"desc": current_desc})},
{"key": ("|r[c]ancel", "c"),
"goto": "node_start"})
return text, options
def node_create_room(caller, raw_string, **kwargs):
text = _CREATE_ROOM_TEXT
options = (
{"key": ("|g[c]reate new room and start game|n", "c"),
"goto": _create_new_room},
{"key": ("|r[a]bort and go back|n", "a"),
"goto": "node_start"})
return text, options
def node_join_room(caller, raw_string, **kwargs):
room = kwargs['room']
stats = room.db.stats or {"progress": 0}
players = [char.key for char in room.get_all_characters()]
text = _JOIN_EXISTING_ROOM_TEXT.format(
roomname=room.get_display_name(caller),
percent=int(stats['progress']),
nplayers=len(players),
players=list_to_string(players)
)
options = (
{"key": ("|g[a]ccept|n (default)", "a"),
"goto": (_move_to_room, kwargs)},
{"key": ("|r[c]ancel|n", "c"),
"goto": "node_start"},
{"key": "_default",
"goto": (_move_to_room, kwargs)})
return text, options
def node_quit(caller, raw_string, **kwargs):
quiet = kwargs.get("quiet")
text = ""
if not quiet:
text = "Goodbye for now!\n"
# we check an Attribute on the caller to see if we should
# leave the game entirely when leaving
if caller.db.evscaperoom_standalone:
from evennia.commands import cmdhandler
from evennia import default_cmds
cmdhandler.cmdhandler(caller.ndb._menutree._session, "",
cmdobj=default_cmds.CmdQuit(),
cmdobj_key="@quit")
return text, None # empty options exit the menu
class EvscaperoomMenu(EvMenu):
"""
Custom menu with a different formatting of options.
"""
node_border_char = "~"
def nodetext_formatter(self, text):
return justify(text.strip("\n").rstrip(), align='c', indent=1)
def options_formatter(self, optionlist):
main_options = []
room_choices = []
for key, desc in optionlist:
if key.isdigit():
room_choices.append((key, desc))
else:
main_options.append(key)
main_options = " | ".join(main_options)
room_choices = super().options_formatter(room_choices)
return "{}{}{}".format(main_options,
"\n\n" if room_choices else "",
room_choices)
# access function
def run_evscaperoom_menu(caller):
"""
Run room selection menu
"""
menutree = {"node_start": node_start,
"node_quit": node_quit,
"node_set_desc": node_set_desc,
"node_create_room": node_create_room,
"node_join_room": node_join_room}
EvscaperoomMenu(caller, menutree, startnode="node_start",
cmd_on_exit=None, auto_quit=True)
# ------------------------------------------------------------
# In-game Options menu
# ------------------------------------------------------------
def _set_thing_style(caller, raw_string, **kwargs):
room = caller.location
options = caller.attributes.get("options", category=room.tagcategory, default={})
options["things_style"] = kwargs.get("value", 2)
caller.attributes.add("options", options, category=room.tagcategory)
return None, kwargs # rerun node
def _toggle_screen_reader(caller, raw_string, **kwargs):
session = kwargs['session']
# flip old setting
session.protocol_flags["SCREENREADER"] = not session.protocol_flags.get("SCREENREADER", False)
# sync setting with portal
session.sessionhandler.session_portal_sync(session)
return None, kwargs # rerun node
def node_options(caller, raw_string, **kwargs):
text = "|cOption menu|n\n('|wq|nuit' to return)"
room = caller.location
options = caller.attributes.get("options", category=room.tagcategory, default={})
things_style = options.get("things_style", 2)
session = kwargs['session'] # we give this as startnode_input when starting menu
screenreader = session.protocol_flags.get("SCREENREADER", False)
options = (
{"desc": "{}No item markings (hard mode)".format(
"|g(*)|n " if things_style == 0 else "( ) "),
"goto": (_set_thing_style, {"value": 0, 'session': session})},
{"desc": "{}Items marked as |yitem|n (with color)".format(
"|g(*)|n " if things_style == 1 else "( ) "),
"goto": (_set_thing_style, {"value": 1, 'session': session})},
{"desc": "{}Items are marked as |y[item]|n (screenreader friendly)".format(
"|g(*)|n " if things_style == 2 else "( ) "),
"goto": (_set_thing_style, {"value": 2, 'session': session})},
{"desc": "{}Screenreader mode".format(
"(*) " if screenreader else "( ) "),
"goto": (_toggle_screen_reader, kwargs)})
return text, options
class OptionsMenu(EvMenu):
"""
Custom display of Option menu
"""
def node_formatter(self, nodetext, optionstext):
return f"{nodetext}\n\n{optionstext}"
# access function
def run_option_menu(caller, session):
"""
Run option menu in-game
"""
menutree = {"node_start": node_options}
OptionsMenu(caller, menutree, startnode="node_start",
cmd_on_exit="look", auto_quit=True, startnode_input=("", {"session": session}))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
"""
Room class and mechanics for the Evscaperoom.
This is a special room class that not only depicts the evscaperoom itself, it
also acts as a central store for the room state, score etc. When deleting this,
that particular escaperoom challenge should be gone.
"""
from evennia import DefaultRoom, DefaultCharacter, DefaultObject
from evennia import utils
from evennia.utils.ansi import strip_ansi
from evennia import logger
from evennia.locks.lockhandler import check_lockstring
from evennia.utils.utils import lazy_property, list_to_string
from .objects import EvscaperoomObject
from .commands import CmdSetEvScapeRoom
from .state import StateHandler
class EvscapeRoom(EvscaperoomObject, DefaultRoom):
"""
The room to escape from.
"""
def at_object_creation(self):
"""
Called once, when the room is first created.
"""
super().at_object_creation()
# starting state
self.db.state = None # name
self.db.prev_state = None
# this is used for tagging of all objects belonging to this
# particular room instance, so they can be cleaned up later
# this is accessed through the .tagcategory getter.
self.db.tagcategory = "evscaperoom_{}".format(self.key)
# room progress statistics
self.db.stats = {
"progress": 0, # in percent
"score": {}, # reason: score
"max_score": 100,
"hints_used": 0, # total across all states
"hints_total": 41,
"total_achievements": 14
}
self.cmdset.add(CmdSetEvScapeRoom, permanent=True)
self.log("Room created and log started.")
@lazy_property
def statehandler(self):
return StateHandler(self)
@property
def state(self):
return self.statehandler.current_state
def log(self, message, caller=None):
"""
Log to a file specificially for this room.
"""
caller = f"[caller.key]: " if caller else ""
logger.log_file(
strip_ansi(f"{caller}{message.strip()}"),
filename=self.tagcategory + ".log")
def score(self, new_score, reason):
"""
We don't score individually but for everyone in room together.
You can only be scored for a given reason once."""
if reason not in self.db.stats['score']:
self.log(f"score: {reason} ({new_score}pts)")
self.db.stats['score'][reason] = new_score
def progress(self, new_progress):
"Progress is what we set it to be (0-100%)"
self.log(f"progress: {new_progress}%")
self.db.stats['progress'] = new_progress
def achievement(self, caller, achievement, subtext=""):
"""
Give the caller a personal achievment. You will only
ever get one of the same type
Args:
caller (Object): The receiver of the achievement.
achievement (str): The title/name of the achievement.
subtext (str, optional): Eventual subtext/explanation
of the achievement.
"""
achievements = caller.attributes.get(
"achievements", category=self.tagcategory)
if not achievements:
achievements = {}
if achievement not in achievements:
self.log(f"achievement: {caller} earned '{achievement}' - {subtext}")
achievements[achievement] = subtext
caller.attributes.add("achievements", achievements, category=self.tagcategory)
def get_all_characters(self):
"""
Get the player characters in the room.
Returns:
chars (Queryset): The characters.
"""
return DefaultCharacter.objects.filter_family(db_location=self)
def set_flag(self, flagname):
self.db.flags[flagname] = True
def unset_flag(self, flagname):
if flagname in self.db.flags:
del self.db.flags[flagname]
def check_flag(self, flagname):
return self.db.flags.get(flagname, False)
def check_perm(self, caller, permission):
return check_lockstring(caller, f"dummy:perm({permission})")
def tag_character(self, character, tag, category=None):
"""
Tag a given character in this room.
Args:
character (Character): Player character to tag.
tag (str): Tag to set.
category (str, optional): Tag-category. If unset, use room's
tagcategory.
"""
category = category if category else self.db.tagcategory
character.tags.add(tag, category=category)
def tag_all_characters(self, tag, category=None):
"""
Set a given tag on all players in the room.
Args:
room (EvscapeRoom): The room to escape from.
tag (str): The tag to set.
category (str, optional): If unset, will use the room's tagcategory.
"""
category = category if category else self.tagcategory
for char in self.get_all_characters():
char.tags.add(tag, category=category)
def character_cleanup(self, char):
"""
Clean all custom tags/attrs on a character.
"""
if self.tagcategory:
char.tags.remove(category=self.tagcategory)
char.attributes.remove(category=self.tagcategory)
def character_exit(self, char):
"""
Have a character exit the room - return them to the room menu.
"""
self.log(f"EXIT: {char} left room")
from .menu import run_evscaperoom_menu
self.character_cleanup(char)
char.location = char.home
# check if room should be deleted
if len(self.get_all_characters()) < 1:
self.delete()
# we must run menu after deletion so we don't include this room!
run_evscaperoom_menu(char)
# Evennia hooks
def at_object_receive(self, moved_obj, source_location):
"""
Called when an object arrives in the room. This can be used to
sum up the situation, set tags etc.
"""
if utils.inherits_from(moved_obj, "evennia.objects.objects.DefaultCharacter"):
self.log(f"JOIN: {moved_obj} joined room")
self.state.character_enters(moved_obj)
def at_object_leave(self, moved_obj, target_location, **kwargs):
"""
Called when an object leaves the room; if this is a Character we need
to clean them up and move them to the menu state.
"""
if utils.inherits_from(moved_obj, "evennia.objects.objects.DefaultCharacter"):
self.character_cleanup(moved_obj)
if len(self.get_all_characters()) <= 1:
# after this move there'll be no more characters in the room - delete the room!
self.delete()
# logger.log_info("DEBUG: Don't delete room when last player leaving")
def delete(self):
"""
Delete this room and all items related to it. Only move the players.
"""
self.db.deleting = True
for char in self.get_all_characters():
self.character_exit(char)
for obj in self.contents:
obj.delete()
self.log("END: Room cleaned up and deleted")
return super().delete()
def return_appearance(self, looker, **kwargs):
obj, pos = self.get_position(looker)
pos = (f"\n|x[{self.position_prep_map[pos]} on "
f"{obj.get_display_name(looker)}]|n" if obj else "")
admin_only = ""
if self.check_perm(looker, "Admin"):
# only for admins
objs = DefaultObject.objects.filter_family(
db_location=self).exclude(id=looker.id)
admin_only = "\n|xAdmin only: " + \
list_to_string([obj.get_display_name(looker) for obj in objs])
return f"{self.db.desc}{pos}{admin_only}"

View file

@ -0,0 +1,32 @@
"""
A simple cleanup script to wipe empty rooms
(This can happen if users leave 'uncleanly', such as by closing their browser
window)
Just start this global script manually or at server creation.
"""
from evennia import DefaultScript
from evscaperoom.room import EvscapeRoom
class CleanupScript(DefaultScript):
def at_script_creation(self):
self.key = "evscaperoom_cleanup"
self.desc = "Cleans up empty evscaperooms"
self.interval = 60 * 15
self.persistent = True
def at_repeat(self):
for room in EvscapeRoom.objects.all():
if not room.get_all_characters():
# this room is empty
room.log("END: Room cleaned by garbage collector.")
room.delete()

View file

@ -0,0 +1,293 @@
"""
States represent the sequence of states the room goes through.
This module includes the BaseState class and the StateHandler
for managing states on the room.
The state handler operates on an Evscaperoom and changes
its state from one to another.
A given state is given as a module in states/ package. The
state is identified by its module name.
"""
from django.conf import settings
from functools import wraps
from evennia import utils
from evennia import logger
from .objects import EvscaperoomObject
from .utils import create_evscaperoom_object, msg_cinematic, parse_for_things
# state setup
if hasattr(settings, "EVSCAPEROOM_STATE_PACKAGE"):
_ROOMSTATE_PACKAGE = settings.EVSCAPEROOM_STATE_PACKAGE
else:
_ROOMSTATE_PACKAGE = "evennia.contrib.evscaperoom.states"
if hasattr(settings, "EVSCAPEROOM_START_STATE"):
_FIRST_STATE = settings.EVSCAPEROOM_START_STATE
else:
_FIRST_STATE = "state_001_start"
_GA = object.__getattribute__
# handler for managing states on room
class StateHandler(object):
"""
This sits on the room and is used to progress through the states.
"""
def __init__(self, room):
self.room = room
self.current_state_name = room.db.state or _FIRST_STATE
self.prev_state_name = room.db.prev_state
self.current_state = None
self.current_state = self.load_state(self.current_state_name)
def load_state(self, statename):
"""
Load state without initializing it
"""
try:
mod = utils.mod_import(f"{_ROOMSTATE_PACKAGE}.{statename}")
except Exception as err:
logger.log_trace()
self.room.msg_room(None, f"|rBUG: Could not load state {statename}: {err}!")
self.room.msg_room(None, f"|rBUG: Falling back to {self.current_state_name}")
return
state = mod.State(self, self.room)
return state
def init_state(self):
"""
Initialize a new state
"""
self.current_state.init()
def next_state(self, next_state=None):
"""
Check if the current state is finished. This should be called whenever
the players do actions that may affect the state of the room.
Args:
next_state (str, optional): If given, override the next_state given
by the current state's check() method with this - this allows
for branching paths (but the current state must still first agree
that the check passes).
Returns:
state_changed (bool): True if the state changed, False otherwise.
"""
# allows the state to enforce/customize what the next state should be
next_state_name = self.current_state.next(next_state)
if next_state_name:
# we are ready to move on!
next_state = self.load_state(next_state_name)
if not next_state:
raise RuntimeError(f"Could not load new state {next_state_name}!")
self.prev_state_name = self.current_state_name
self.current_state_name = next_state_name
self.current_state.clean()
self.prev_state = self.current_state
self.current_state = next_state
self.init_state()
self.room.db.prev_state = self.prev_state_name
self.room.db.state = self.current_state_name
return True
return False
# base state class
class BaseState(object):
"""
Base object holding all callables for a state. This is here to
allow easy overriding for child states.
"""
next_state = "unset"
# a sequence of hints to describe this state.
hints = []
def __init__(self, handler, room):
"""
Initializer.
Args:
room (EvscapeRoom): The room tied to this state.
handler (StateHandler): Back-reference to the handler
storing this state.
"""
self.handler = handler
self.room = room
# the name is derived from the name of the module
self.name = self.__class__.__module__
def __str__(self):
return self.__class__.__module__
def __repr__(self):
return str(self)
def _catch_errors(self, method):
"""
Wrapper handling state method errors.
"""
@wraps(method)
def decorator(*args, **kwargs):
try:
return method(*args, **kwargs)
except Exception:
logger.log_trace(f"Error in State {__name__}")
self.room.msg_room(None, f"|rThere was an unexpected error in State {__name__}. "
"Please |wreport|r this as an issue.|n")
raise # TODO
return decorator
def __getattribute__(self, key):
"""
Always wrap all callables in the error-handler
"""
val = _GA(self, key)
if callable(val):
return _GA(self, "_catch_errors")(val)
return val
def get_hint(self):
"""
Get a hint for how to solve this state.
"""
hint_level = self.room.attributes.get("state_hint_level", default=-1)
next_level = hint_level + 1
if next_level < len(self.hints):
# return the next hint in the sequence.
self.room.db.state_hint_level = next_level
self.room.db.stats["hints_used"] += 1
self.room.log(f"HINT: {self.name.split('.')[-1]}, level {next_level + 1} "
f"(total used: {self.room.db.stats['hints_used']})")
return self.hints[next_level]
else:
# no more hints for this state
return None
# helpers
def msg(self, message, target=None, borders=False, cinematic=False):
"""
Display messsage to everyone in room, or given target.
"""
if cinematic:
message = msg_cinematic(message, borders=borders)
if target:
options = target.attributes.get(
"options", category=self.room.tagcategory, default={})
style = options.get("things_style", 2)
# we assume this is a char
target.msg(parse_for_things(message, things_style=style))
else:
self.room.msg_room(None, message)
def cinematic(self, message, target=None):
"""
Display a 'cinematic' sequence - centered, with borders.
"""
self.msg(message, target=target, borders=True, cinematic=True)
def create_object(self, typeclass=None, key='testobj', location=None, **kwargs):
"""
This is a convenience-wrapper for quickly building EvscapeRoom objects.
Kwargs:
typeclass (str): This can take just the class-name in the evscaperoom's
objects.py module. Otherwise, a full path or the actual class
is needed (for custom state objects, just give the class directly).
key (str): Name of object.
location (Object): If not given, this will be the current room.
kwargs (any): Will be passed into create_object.
Returns:
new_obj (Object): The newly created object, if any.
"""
if not location:
location = self.room
return create_evscaperoom_object(
typeclass=typeclass, key=key, location=location,
tags=[("room", self.room.tagcategory.lower())], **kwargs)
def get_object(self, key):
"""
Find a named *non-character* object for this state in this room.
Args:
key (str): Object to search for.
Returns:
obj (Object): Object in the room.
"""
match = EvscaperoomObject.objects.filter_family(
db_key__iexact=key, db_tags__db_category=self.room.tagcategory.lower())
if not match:
logger.log_err(f"get_object: No match for '{key}' in state ")
return None
return match[0]
# state methods
def init(self):
"""
Initializes the state (usually by modifying the room in some way)
"""
pass
def clean(self):
"""
Any cleanup operations after the state ends.
"""
self.room.db.state_hint_level = -1
def next(self, next_state=None):
"""
Get the next state after this one.
Args:
next_state (str, optional): This allows the calling code
to redirect to a different state than the 'default' one
(creating branching paths in the game). Override this method
to customize (by default the input will always override default
set on the class)
Returns:
state_name (str or None): Name of next state to switch to. None
to remain in this state. By default we check the room for the
"finished" flag be set.
"""
return next_state or self.next_state
def character_enters(self, character):
"""
Called when character enters the room in this state.
"""
pass
def character_leaves(self, character):
"""
Called when character is whisked away (usually because of
quitting). This method cannot influence the move itself; it
happens just before room.character_cleanup()
"""
pass

View file

@ -0,0 +1,23 @@
# Room state modules
The Evscaperoom goes through a series of 'states' as the players solve puzzles
and progress towards escaping. The States are managed by the StateHandler. When
a certain set of criteria (different for each state) have been fulfilled, the
state ends by telling the StateHandler what state should be loaded next.
A 'state' is a series of Python instructions in a module. A state could mean
the room description changing or new objects appearing, or flag being set that
changes the behavior of existing objects.
The states are stored in Python modules, with file names on the form
`state_001_<name_of_state>.py`. The numbers help organize the states in the file
system but they don't necessarily need to follow each other in the exact
sequence.
Each state module must make a class `State` available in the global scope. This
should be a child of `evennia.contribs.evscaperoom.state.BaseState`. The
methods on this class will be called to initialize the state and clean up etc.
There are no other restrictions on the module.
The first state (*when the room is created) defaults to being `state_001_start.py`,
this can be changed with `settings.EVSCAPEROOM_STATE_STATE`.

View file

@ -0,0 +1,176 @@
"""
First room state
This simple example sets up an empty one-state room with a door and a key.
After unlocking, opening the door and leaving the room, the player is
teleported back to the evscaperoom menu.
"""
from evennia.contrib.evscaperoom.state import BaseState
from evennia.contrib.evscaperoom import objects
GREETING = """
This is the situation, {name}:
You are locked in this room ... get out! Simple as that!
"""
ROOM_DESC = """
This is a featureless room. On one wall is a *door. On the other wall is a
*button marked "GET HELP".
There is a *key lying on the floor.
"""
# Example object
DOOR_DESC = """
This is a simple example door leading out of the room.
"""
class Door(objects.Openable):
"""
The door leads out of the room.
"""
start_open = False
def at_object_creation(self):
super().at_object_creation()
self.set_flag("door") # target for key
def at_open(self, caller):
# only works if the door was unlocked
self.msg_room(caller, f"~You ~open *{self.key}")
def at_focus_leave(self, caller, **kwargs):
if self.check_flag("open"):
self.msg_room(caller, "~You ~leave the room!")
self.msg_char(caller, "Congrats!")
# exit evscaperoom
self.room.character_exit(caller)
else:
self.msg_char(caller, "The door is closed. You cannot leave!")
# key
KEY_DESC = """
A simple room key. A paper label is attached to it.
"""
KEY_READ = """
A paper label is attached to the key. It reads.
|rOPEN THE DOOR WITH ME|n
A little on the nose, but this is an example room after all ...
"""
KEY_APPLY = """
~You insert the *key into the *door, turns it and ... the door unlocks!
"""
class Key(objects.Insertable, objects.Readable):
"A key for opening the door"
# where this key applies (must be flagged as such)
target_flag = "door"
def at_apply(self, caller, action, obj):
obj.set_flag("unlocked") # unlocks the door
self.msg_room(caller, KEY_APPLY.strip())
def at_read(self, caller, *args, **kwargs):
self.msg_char(caller, KEY_READ.strip())
def get_cmd_signatures(self):
return [], "You can *read the label or *insert the key into something."
# help button
BUTTON_DESC = """
On the wall is a button marked
PRESS ME FOR HELP
"""
class HelpButton(objects.EvscaperoomObject):
def at_focus_push(self, caller, **kwargs):
"this adds the 'push' action to the button"
hint = self.room.state.get_hint()
if hint is None:
self.msg_char(caller, "There are no more hints to be had.")
else:
self.msg_room(caller, f"{caller.key} pushes *button and gets the "
f"hint:\n \"{hint.strip()}\"|n")
# state
STATE_HINT_LVL1 = """
The door is locked. What is usually used for unlocking doors?
"""
STATE_HINT_LVL2 = """
This is just an example. Do what comes naturally. Examine what's on the floor.
"""
STATE_HINT_LVL3 = """
Insert the *key in the *door. Then open the door and leave! Yeah, it's really
that simple.
"""
class State(BaseState):
"""
This class (with exactly this name) must exist in every state module.
"""
# this makes these hints available to the .get_hint method.
hints = [STATE_HINT_LVL1,
STATE_HINT_LVL2,
STATE_HINT_LVL3]
def character_enters(self, char):
"Called when char enters room at this state"
self.cinematic(GREETING.format(name=char.key))
def init(self):
"Initialize state"
# describe the room
self.room.db.desc = ROOM_DESC
# create the room objects
door = self.create_object(
Door, key="door to the cabin", aliases=["door"])
door.db.desc = DOOR_DESC.strip()
key = self.create_object(
Key, key="key", aliases=["room key"])
key.db.desc = KEY_DESC.strip()
button = self.create_object(
HelpButton, key="button", aliases=["help button"])
button.db.desc = BUTTON_DESC.strip()
def clean(self):
"Cleanup operations on the state, when it's over"
super().clean()

View file

@ -0,0 +1,313 @@
"""
Unit tests for the Evscaperoom
"""
import inspect
import pkgutil
from os import path
from evennia.commands.default.tests import CommandTest
from evennia import InterruptCommand
from evennia.utils.test_resources import EvenniaTest
from evennia.utils import mod_import
from . import commands
from . import state as basestate
from . import objects
from . import utils
class TestEvscaperoomCommands(CommandTest):
def setUp(self):
super().setUp()
self.room1 = utils.create_evscaperoom_object(
"evscaperoom.room.EvscapeRoom", key='Testroom')
self.char1.location = self.room1
self.obj1.location = self.room1
def test_base_search(self):
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
self.assertEqual((self.obj1, None), cmd._search("Obj", True))
self.assertEqual((None, "Obj"), cmd._search("Obj", False))
self.assertEqual((None, "Foo"), cmd._search("Foo", False))
self.assertEqual((None, "Foo"), cmd._search("Foo", None))
self.assertRaises(InterruptCommand, cmd._search, "Foo", True)
def test_base_parse(self):
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = None
cmd.args = "obj"
cmd.parse()
self.assertEqual(cmd.obj1, self.obj1)
self.assertEqual(cmd.room, self.char1.location)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = False
cmd.obj2_search = False
cmd.args = "obj"
cmd.parse()
self.assertEqual(cmd.arg1, "obj")
self.assertEqual(cmd.obj1, None)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = None
cmd.args = "obj"
cmd.parse()
self.assertEqual(cmd.obj1, self.obj1)
self.assertEqual(cmd.arg1, None)
self.assertEqual(cmd.arg2, None)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = True
cmd.obj2_search = True
cmd.args = "obj at obj"
cmd.parse()
self.assertEqual(cmd.obj1, self.obj1)
self.assertEqual(cmd.obj2, self.obj1)
self.assertEqual(cmd.arg1, None)
self.assertEqual(cmd.arg2, None)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = False
cmd.obj2_search = False
cmd.args = "obj at obj"
cmd.parse()
self.assertEqual(cmd.obj1, None)
self.assertEqual(cmd.obj2, None)
self.assertEqual(cmd.arg1, "obj")
self.assertEqual(cmd.arg2, "obj")
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = None
cmd.args = "obj at obj"
cmd.parse()
self.assertEqual(cmd.obj1, self.obj1)
self.assertEqual(cmd.obj2, self.obj1)
self.assertEqual(cmd.arg1, None)
self.assertEqual(cmd.arg2, None)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = None
cmd.args = "foo in obj"
cmd.parse()
self.assertEqual(cmd.obj1, None)
self.assertEqual(cmd.obj2, self.obj1)
self.assertEqual(cmd.arg1, 'foo')
self.assertEqual(cmd.arg2, None)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = None
cmd.args = "obj on foo"
cmd.parse()
self.assertEqual(cmd.obj1, self.obj1)
self.assertEqual(cmd.obj2, None)
self.assertEqual(cmd.arg1, None)
self.assertEqual(cmd.arg2, 'foo')
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = True
cmd.args = "obj on foo"
self.assertRaises(InterruptCommand, cmd.parse)
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.obj1_search = None
cmd.obj2_search = True
cmd.args = "on obj"
cmd.parse()
self.assertEqual(cmd.obj1, None)
self.assertEqual(cmd.obj2, self.obj1)
self.assertEqual(cmd.arg1, "")
self.assertEqual(cmd.arg2, None)
def test_set_focus(self):
cmd = commands.CmdEvscapeRoom()
cmd.caller = self.char1
cmd.room = self.room1
cmd.focus = self.obj1
self.assertEqual(self.char1.attributes.get(
"focus", category=self.room1.tagcategory), self.obj1)
def test_focus(self):
# don't focus on a non-room object
self.call(commands.CmdFocus(), "obj")
self.assertEqual(self.char1.attributes.get(
"focus", category=self.room1.tagcategory), None)
# should focus correctly
myobj = utils.create_evscaperoom_object(
objects.EvscaperoomObject, "mytestobj", location=self.room1)
self.call(commands.CmdFocus(), "mytestobj")
self.assertEqual(self.char1.attributes.get(
"focus", category=self.room1.tagcategory), myobj)
def test_look(self):
self.call(commands.CmdLook(), "at obj", "Obj")
self.call(commands.CmdLook(), "obj", "Obj")
self.call(commands.CmdLook(), "obj", "Obj")
def test_speech(self):
self.call(commands.CmdSpeak(), "", "What do you want to say?", cmdstring="")
self.call(commands.CmdSpeak(), "Hello!", "You say: Hello!", cmdstring="")
self.call(commands.CmdSpeak(), "", "What do you want to whisper?", cmdstring="whisper")
self.call(commands.CmdSpeak(), "Hi.", "You whisper: Hi.", cmdstring="whisper")
self.call(commands.CmdSpeak(), "Hi.", "You whisper: Hi.", cmdstring="whisper")
self.call(commands.CmdSpeak(), "HELLO!", "You shout: HELLO!", cmdstring="shout")
self.call(commands.CmdSpeak(), "Hello to obj",
"You say: Hello", cmdstring="say")
self.call(commands.CmdSpeak(), "Hello to obj",
"You shout: Hello", cmdstring="shout")
def test_emote(self):
self.call(commands.CmdEmote(),
"/me smiles to /obj",
f"Char(#{self.char1.id}) smiles to Obj(#{self.obj1.id})")
def test_focus_interaction(self):
self.call(commands.CmdFocusInteraction(), "", "Hm?")
class TestUtils(EvenniaTest):
def test_overwrite(self):
room = utils.create_evscaperoom_object(
"evscaperoom.room.EvscapeRoom", key='Testroom')
obj1 = utils.create_evscaperoom_object(
objects.EvscaperoomObject, key="testobj", location=room)
id1 = obj1.id
obj2 = utils.create_evscaperoom_object(
objects.EvscaperoomObject, key="testobj", location=room)
id2 = obj2.id
# we should have created a new object, deleting the old same-named one
self.assertTrue(id1 != id2)
self.assertFalse(bool(obj1.pk))
self.assertTrue(bool(obj2.pk))
def test_parse_for_perspectives(self):
second, third = utils.parse_for_perspectives("~You ~look at the nice book", "TestGuy")
self.assertTrue(second, "You look at the nice book")
self.assertTrue(third, "TestGuy looks at the nice book")
# irregular
second, third = utils.parse_for_perspectives("With a smile, ~you ~were gone", "TestGuy")
self.assertTrue(second, "With a smile, you were gone")
self.assertTrue(third, "With a smile, TestGuy was gone")
def test_parse_for_things(self):
string = "Looking at *book and *key."
self.assertEqual(utils.parse_for_things(string, 0), "Looking at book and key.")
self.assertEqual(utils.parse_for_things(string, 1), "Looking at |ybook|n and |ykey|n.")
self.assertEqual(utils.parse_for_things(string, 2), "Looking at |y[book]|n and |y[key]|n.")
class TestEvScapeRoom(EvenniaTest):
def setUp(self):
super().setUp()
self.room = utils.create_evscaperoom_object(
"evscaperoom.room.EvscapeRoom", key='Testroom',
home=self.room1)
self.roomtag = "evscaperoom_{}".format(self.room.key)
def tearDown(self):
self.room.delete()
def test_room_methods(self):
room = self.room
self.char1.location = room
self.assertEqual(room.tagcategory, self.roomtag)
self.assertEqual(list(room.get_all_characters()), [self.char1])
room.tag_character(self.char1, "opened_door")
self.assertEqual(self.char1.tags.get(
"opened_door", category=self.roomtag), "opened_door")
room.tag_all_characters("tagged_all")
self.assertEqual(self.char1.tags.get(
"tagged_all", category=self.roomtag), "tagged_all")
room.character_cleanup(self.char1)
self.assertEqual(self.char1.tags.get(category=self.roomtag), None)
class TestStates(EvenniaTest):
def setUp(self):
super().setUp()
self.room = utils.create_evscaperoom_object(
"evscaperoom.room.EvscapeRoom", key='Testroom',
home=self.room1)
self.roomtag = "evscaperoom_#{}".format(self.room.id)
def tearDown(self):
self.room.delete()
def _get_all_state_modules(self):
dirname = path.join(path.dirname(__file__), "states")
states = []
for imp, module, ispackage in pkgutil.walk_packages(
path=[dirname], prefix="evscaperoom.states."):
mod = mod_import(module)
states.append(mod)
return states
def test_base_state(self):
st = basestate.BaseState(self.room.statehandler, self.room)
st.init()
obj = st.create_object(objects.Edible, key="apple")
self.assertEqual(obj.key, "apple")
self.assertEqual(obj.__class__, objects.Edible)
obj.delete()
def test_all_states(self):
"Tick through all defined states"
for mod in self._get_all_state_modules():
state = mod.State(self.room.statehandler, self.room)
state.init()
for obj in self.room.contents:
if obj.pk:
methods = inspect.getmembers(obj, predicate=inspect.ismethod)
for name, method in methods:
if name.startswith("at_focus_"):
method(self.char1, args="dummy")
next_state = state.next()
self.assertEqual(next_state, mod.State.next_state)

View file

@ -0,0 +1,187 @@
"""
Helper functions and classes for the evscaperoom contrib.
Most of these are available directly from wrappers in state/object/room classes
and does not need to be imported from here.
"""
import re
from random import choice
from evennia import create_object, search_object
from evennia.utils import justify, inherits_from
_BASE_TYPECLASS_PATH = "evscaperoom.objects."
_RE_PERSPECTIVE = re.compile(r"~(\w+)", re.I+re.U+re.M)
_RE_THING = re.compile(r"\*(\w+)", re.I+re.U+re.M)
def create_evscaperoom_object(typeclass=None, key="testobj", location=None,
delete_duplicates=True, **kwargs):
"""
This is a convenience-wrapper for quickly building EvscapeRoom objects. This
is called from the helper-method create_object on states, but is also useful
for the object-create admin command.
Note that for the purpose of the Evscaperoom, we only allow one instance
of each *name*, deleting the old version if it already exists.
Kwargs:
typeclass (str): This can take just the class-name in the evscaperoom's
objects.py module. Otherwise, a full path is needed.
key (str): Name of object.
location (Object): The location to create new object.
delete_duplicates (bool): Delete old object with same key.
kwargs (any): Will be passed into create_object.
Returns:
new_obj (Object): The newly created object, if any.
"""
if not (callable(typeclass) or
typeclass.startswith("evennia") or
typeclass.startswith("typeclasses") or
typeclass.startswith("evscaperoom")):
# unless we specify a full typeclass path or the class itself,
# auto-complete it
typeclass = _BASE_TYPECLASS_PATH + typeclass
if delete_duplicates:
old_objs = [obj for obj in search_object(key)
if not inherits_from(obj, "evennia.objects.objects.DefaultCharacter")]
if location:
# delete only matching objects in the given location
[obj.delete() for obj in old_objs if obj.location == location]
else:
[obj.delete() for obj in old_objs]
new_obj = create_object(typeclass=typeclass, key=key,
location=location, **kwargs)
return new_obj
def create_fantasy_word(length=5, capitalize=True):
"""
Create a random semi-pronouncable 'word'.
Kwargs:
length (int): The desired length of the 'word'.
capitalize (bool): If the return should be capitalized or not
Returns:
word (str): The fictous word of given length.
"""
if not length:
return ""
phonemes = ("ea oh ae aa eh ah ao aw ai er ey ow ia ih iy oy ua "
"uh uw a e i u y p b t d f v t dh "
"s z sh zh ch jh k ng g m n l r w").split()
word = [choice(phonemes)]
while len(word) < length:
word.append(choice(phonemes))
# it's possible to exceed length limit due to double consonants
word = "".join(word)[:length]
return word.capitalize() if capitalize else word
# special word mappings when going from 2nd person to 3rd
irregulars = {
"were": "was",
"are": "is",
"mix": "mixes",
"push": "pushes",
"have": "has",
"focus": "focuses",
}
def parse_for_perspectives(string, you=None):
"""
Parse a string with special markers to produce versions both
intended for the person doing the action ('you') and for those
seeing the person doing that action. Also marks 'things'
according to style. See example below.
Args:
string (str): String on 2nd person form with ~ markers ('~you ~open ...')
you (str): What others should see instead of you (Bob opens)
Returns:
second, third_person (tuple): Strings replace to be shown in 2nd and 3rd person
perspective
Example:
"~You ~open"
-> "You open", "Bob opens"
"""
def _replace_third_person(match):
match = match.group(1)
lmatch = match.lower()
if lmatch == "you":
return "|c{}|n".format(you)
elif lmatch in irregulars:
if match[0].isupper():
return irregulars[lmatch].capitalize()
return irregulars[lmatch]
elif lmatch[-1] == 's':
return match + "es"
else:
return match + "s" # simple, most normal form
you = "They" if you is None else you
first_person = _RE_PERSPECTIVE.sub(r"\1", string)
third_person = _RE_PERSPECTIVE.sub(_replace_third_person, string)
return first_person, third_person
def parse_for_things(string, things_style=2, clr="|y"):
"""
Parse string for special *thing markers and decorate
it.
Args:
string (str): The string to parse.
things_style (int): The style to handle `*things` marked:
0 - no marking (remove `*`)
1 - mark with color
2 - mark with color and [] (default)
clr (str): Which color to use for marker..
Example:
You open *door -> You open [door].
"""
if not things_style:
# hardcore mode - no marking of focus targets
return _RE_THING.sub(r"\1", string)
elif things_style == 1:
# only colors
return _RE_THING.sub(r"{}\1|n".format(clr), string)
else:
# colors and brackets
return _RE_THING.sub(r"{}[\1]|n".format(clr), string)
def add_msg_borders(text):
"Add borders above/below text block"
maxwidth = max(len(line) for line in text.split("\n"))
sep = "|w" + "~" * maxwidth + "|n"
text = f"{sep}\n{text}\n{sep}"
return text
def msg_cinematic(text, borders=True):
"""
Display a text as a 'cinematic' - centered and
surrounded by borders.
Args:
text (str): Text to format.
borders (bool, optional): Put borders above and below text.
Returns:
text (str): Pretty-formatted text.
"""
text = text.strip()
text = justify(text, align='c', indent=1)
if borders:
text = add_msg_borders(text)
return text