mirror of
https://github.com/evennia/evennia.git
synced 2026-03-26 01:36:32 +01:00
Add the full EvscapeRoom game engine as a contrib
This commit is contained in:
parent
6c02b68278
commit
a00cc681d9
13 changed files with 3509 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
evennia/contrib/evscaperoom/README.md
Normal file
116
evennia/contrib/evscaperoom/README.md
Normal 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.
|
||||
752
evennia/contrib/evscaperoom/commands.py
Normal file
752
evennia/contrib/evscaperoom/commands.py
Normal 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())
|
||||
325
evennia/contrib/evscaperoom/menu.py
Normal file
325
evennia/contrib/evscaperoom/menu.py
Normal 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}))
|
||||
1052
evennia/contrib/evscaperoom/objects.py
Normal file
1052
evennia/contrib/evscaperoom/objects.py
Normal file
File diff suppressed because it is too large
Load diff
237
evennia/contrib/evscaperoom/room.py
Normal file
237
evennia/contrib/evscaperoom/room.py
Normal 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}"
|
||||
32
evennia/contrib/evscaperoom/scripts.py
Normal file
32
evennia/contrib/evscaperoom/scripts.py
Normal 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()
|
||||
293
evennia/contrib/evscaperoom/state.py
Normal file
293
evennia/contrib/evscaperoom/state.py
Normal 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
|
||||
23
evennia/contrib/evscaperoom/states/README.md
Normal file
23
evennia/contrib/evscaperoom/states/README.md
Normal 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`.
|
||||
176
evennia/contrib/evscaperoom/states/state_001_start.py
Normal file
176
evennia/contrib/evscaperoom/states/state_001_start.py
Normal 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()
|
||||
313
evennia/contrib/evscaperoom/tests.py
Normal file
313
evennia/contrib/evscaperoom/tests.py
Normal 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)
|
||||
187
evennia/contrib/evscaperoom/utils.py
Normal file
187
evennia/contrib/evscaperoom/utils.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue