From a00cc681d91f4d0ffbea1d59d2297032bf325a5e Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 18 Jun 2019 19:38:53 +0200 Subject: [PATCH] Add the full EvscapeRoom game engine as a contrib --- CHANGELOG.md | 2 + evennia/contrib/README.md | 1 + evennia/contrib/evscaperoom/README.md | 116 ++ evennia/contrib/evscaperoom/commands.py | 752 ++++++++++++ evennia/contrib/evscaperoom/menu.py | 325 +++++ evennia/contrib/evscaperoom/objects.py | 1052 +++++++++++++++++ evennia/contrib/evscaperoom/room.py | 237 ++++ evennia/contrib/evscaperoom/scripts.py | 32 + evennia/contrib/evscaperoom/state.py | 293 +++++ evennia/contrib/evscaperoom/states/README.md | 23 + .../evscaperoom/states/state_001_start.py | 176 +++ evennia/contrib/evscaperoom/tests.py | 313 +++++ evennia/contrib/evscaperoom/utils.py | 187 +++ 13 files changed, 3509 insertions(+) create mode 100644 evennia/contrib/evscaperoom/README.md create mode 100644 evennia/contrib/evscaperoom/commands.py create mode 100644 evennia/contrib/evscaperoom/menu.py create mode 100644 evennia/contrib/evscaperoom/objects.py create mode 100644 evennia/contrib/evscaperoom/room.py create mode 100644 evennia/contrib/evscaperoom/scripts.py create mode 100644 evennia/contrib/evscaperoom/state.py create mode 100644 evennia/contrib/evscaperoom/states/README.md create mode 100644 evennia/contrib/evscaperoom/states/state_001_start.py create mode 100644 evennia/contrib/evscaperoom/tests.py create mode 100644 evennia/contrib/evscaperoom/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1786e048..0b73dc9692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 661183bbb8..1748e868e1 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -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 diff --git a/evennia/contrib/evscaperoom/README.md b/evennia/contrib/evscaperoom/README.md new file mode 100644 index 0000000000..8cf8930e74 --- /dev/null +++ b/evennia/contrib/evscaperoom/README.md @@ -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 ` 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. diff --git a/evennia/contrib/evscaperoom/commands.py b/evennia/contrib/evscaperoom/commands.py new file mode 100644 index 0000000000..8bd65d2e1f --- /dev/null +++ b/evennia/contrib/evscaperoom/commands.py @@ -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) +- ` ` - 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/: |n - free-form emote. Use /me to refer + to yourself and /name to refer to other + things/players. Use quotes "..." to speak. + - |wsay/; |n - quick-speak your mind + - |wwhisper |n - whisper your mind + - |wshout |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 [|] [ |] + + """ + # 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 + 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 + : + + 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: + [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: + [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_(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 or + + """ + 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 with + + """ + 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 with ") + 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 + + """ + 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()) diff --git a/evennia/contrib/evscaperoom/menu.py b/evennia/contrib/evscaperoom/menu.py new file mode 100644 index 0000000000..5fabcf6df0 --- /dev/null +++ b/evennia/contrib/evscaperoom/menu.py @@ -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})) diff --git a/evennia/contrib/evscaperoom/objects.py b/evennia/contrib/evscaperoom/objects.py new file mode 100644 index 0000000000..ed3cc698c5 --- /dev/null +++ b/evennia/contrib/evscaperoom/objects.py @@ -0,0 +1,1052 @@ +""" +Base objects for the Evscaperoom contrib. + + +The object class itself provide the actions possible to use on that object. +This makes these objects suitable for use with multi-inheritance. For example, +to make an object both possible to smell and eat or drink, find the appropriate +parents in this module and make an object like this: + +class Apple(Edible, Smellable): + + def at_drink(self, caller): + # ... + + def at_smell(self, caller): + # ... + +Various object parents could be more complex, so read the class for more info. + +Available parents: + +- EvscapeRoomObject - parent class for all Evscaperoom entities (also the room itself) +- Feelable +- Listenable +- Smellable +- Rotatable +- Openable +- Readable +- IndexReadable (like a lexicon you have to give a search term in) +- Movable +- Edible +- Drinkable +- Usable +- Insertable (can be inserted into a target) +- Combinable (combines with another object to create a new one) +- Mixable (used for mixing potions into it) +- HasButtons (an object with buttons on it) +- CodeInput (code locks) +- Sittable (can be sat on) +- Liable (can be lied down on) +- Kneeable (can be kneed down on) +- Climbable (can be climbed on) +- Positionable (supports sit/lie/knee/climb at once) + +""" +import re +import inspect +from evennia import DefaultObject +from evennia.utils.utils import list_to_string, wrap +from .utils import create_evscaperoom_object +from .utils import parse_for_perspectives, parse_for_things + + +class EvscaperoomObject(DefaultObject): + """ + Default object base for all objects related to the contrib. + + """ + # these will be automatically filtered out by self.parse for + # focus-commands using arguments like (`combine [with] object`) + # override this per-class as necessary. + action_prepositions = ("in", "with", "on", "into", "to") + + # this mapping allows for prettier descriptions of our current + # position + position_prep_map = {"sit": "sitting", + "kneel": "kneeling", + "lie": "lying", + "climb": "standing"} + + def at_object_creation(self): + """ + Called once when object is first created. + + """ + # state flags (setup/reset for each state). + self.db.tagcategory = None + self.db.flags = {} + + self.db.desc = "Nothing of interest." + + self.db.positions = {} + + _tagcategory = None + + @property + def tagcategory(self): + if not self._tagcategory: + self._tagcategory = (self.location.db.tagcategory + if self.location else self.db.tagcategory) + return self._tagcategory + + @property + def room(self): + return self.location or self + + @property + def roomstate(self): + return self.room.statehandler.current_state + + def next_state(self, statename=None): + """ + Helper to have the object switch the room to next state + + Args: + statename (str, optional): If given, move to this + state next. Otherwise use the default next-state + of the current state. + + """ + self.room.statehandler.next_state(next_state=statename) + + def set_flag(self, flagname): + "Set flag on object" + self.db.flags[flagname] = True + + def unset_flag(self, flagname): + "Unset flag on object" + if flagname in self.db.flags: + del self.db.flags[flagname] + + def check_flag(self, flagname): + "Check if flag is set on this object" + return self.db.flags.get(flagname, False) + + def set_character_flag(self, char, flagname, value=True): + "Set flag on character" + flags = char.attributes.get(flagname, category=self.tagcategory, default={}) + flags[flagname] = value + char.attributes.add(flagname, flags, category=self.tagcategory) + + def unset_character_flag(self, char, flagname): + "Set flag on character" + flags = char.attributes.get(flagname, category=self.tagcategory, default={}) + if flagname in flags: + flags.pop(flagname, None) + char.attributes.add(flagname, flags, category=self.tagcategory) + + def check_character_flag(self, char, flagname): + "Check if flag is set on character" + flags = char.attributes.get(flagname, category=self.tagcategory, default={}) + return flags.get(flagname, False) + + def msg_room(self, caller, string, skip_caller=False): + """ + Message everyone in the room with a message that is parsed for + ~first/third person grammar, as well as for *thing markers. + + Args: + caller (Object or None): Sender of the message. If None, there + is no sender. + string (str): Message to parse and send to the room. + skip_caller (bool): Send to everyone except caller. + + Notes: + Messages sent by this method will be tagged with a type of + 'your_action' and `others_action`. This is an experiment for + allowing users of e.g. the webclient to redirect messages to + differnt windows. + + """ + you = caller.key if caller else "they" + first_person, third_person = parse_for_perspectives(string, you=you) + for char in self.room.get_all_characters(): + options = char.attributes.get( + "options", category=self.room.tagcategory, default={}) + style = options.get("things_style", 2) + if char == caller: + if not skip_caller: + txt = parse_for_things(first_person, things_style=style) + char.msg((txt, {'type': 'your_action'})) + else: + txt = parse_for_things(third_person, things_style=style) + char.msg((txt, {'type': 'others_action'})) + + def msg_char(self, caller, string, client_type="your_action"): + """ + Send message only to caller (not to the room at large) + + """ + # we must clean away markers + first_person, _ = parse_for_perspectives(string) + options = caller.attributes.get( + "options", category=self.room.tagcategory, default={}) + style = options.get("things_style", 2) + txt = parse_for_things(first_person, things_style=style) + caller.msg((txt, {"type": client_type})) + + def msg_system(self, message, target=None, borders=True): + """ + Send a 'system message' by using the State.msg function. + """ + self.room.state.msg(message, target=target, borders=borders) + + def get_position(self, caller): + """ + Get position of caller on this object (like lying, sitting, kneeling, + standing). See the Positionable child class. + + Args: + caller (Object): The one position we seek. + + Returns: + obj, pos (Object, str): The object we have a position relative to, + as well as the name of that position (lying, sitting, kneeling, + standing). If these are None, it means we are standing on the + floor. + + """ + pos = caller.attributes.get("position", category=self.tagcategory) + if pos: + obj, old_position = pos + return obj, old_position + return None, None + + def set_position(self, caller, new_position): + """ + Set position of caller (like lying, sitting, kneeling, standing) + on this object. See Positionable child class. + + Args: + caller (Object): The one positioning themselves on this object. + new_position (str, None): One of "lie", "kneel", "sit" or "stand". + If `None`, remove position (character stands normally on the + floor). + + """ + if new_position is None: + # reset position + caller.attributes.remove( + "position", category=self.tagcategory) + if caller in self.db.positions: + del self.db.positions[caller] + else: + # set a new position on this object + position = (self, new_position) + caller.attributes.add("position", position, category=self.tagcategory) + self.db.positions[caller] = new_position + + def at_focus(self, caller): + """ + Called when somone is focusing on this object. + + Args: + caller (Character): The one doing the focusing. + + """ + self.msg_char(caller, caller.at_look(self), client_type="look") + + def at_unfocus(self, caller): + """ + Called when focus leaves this object. Note that more than one caller + may be focusing on the object at the same time, so we should not change + the state of the object itself here! + + Args: + caller (Character): The one doing the unfocusing. + + """ + pass + + def at_speech(self, speaker, action): + """ + We don't use the default at_say hook since we handle the send logic in + the command. This is only meant to trigger eventual game-events when + speaking to an object or the room. + + Args: + speaker (Character): The one speaking. + action (str): One of 'say', 'whisper' or 'shout' + + """ + pass + + def parse(self, args): + """ + Simple parser of focus arguments starting with a preposition, + like 'combine with ' <- we want to strip out the preposition + here. + + """ + args = re.sub(r"|".join(r"^{}\s".format(prep) for prep in self.action_prepositions), + "", args) + return args + + def get_cmd_signatures(self): + """ + This allows the object to return more detailed call signs + for each of their at_focus_* methods. This is useful for + things like detailed arguments (only 'move' but 'move left/right') + + Returns: + callsigns (list, None): List of strings to inject into the + available action list produced by `self.get_help`. If `None`, + automatically find actions based on the method names. + custom_helpstr (str): This should be the help text for + the command with a marker `{callsigns}` for where to + inject the list of callsigns. + + """ + command_signatures = [] + helpstr = "" + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for name, method in methods: + if name.startswith("at_focus_"): + command_signatures.append(name[9:]) + command_signatures = sorted(command_signatures) + + if len(command_signatures) == 1: + helpstr = (f"It looks like {self.key} may be " + "suitable to {callsigns}.") + else: + helpstr = (f"At first glance, it looks like {self.key} might be " + "suitable to {callsigns}.") + return command_signatures, helpstr + + def get_short_desc(self, full_desc): + """ + Extract the first sentence from the desc and use as the short desc. + + """ + mat = re.match(r"(^.*?[.?!])", full_desc.strip(), re.M+re.U+re.I+re.S) + if mat: + return mat.group(0).strip() + return full_desc + + def get_help(self, caller): + """ + Get help about this object. By default we return a + listing of all actions you can do on this object. + + """ + # custom-created signatures. We don't sort these + command_signatures, helpstr = self.get_cmd_signatures() + + callsigns = list_to_string(["*" + sig for sig in command_signatures], endsep="or") + + # parse for *thing markers (use these as items) + options = caller.attributes.get( + "options", category=self.room.tagcategory, default={}) + style = options.get("things_style", 2) + + helpstr = helpstr.format(callsigns=callsigns) + helpstr = parse_for_things(helpstr, style, clr="|w") + return wrap(helpstr, width=80) + + # Evennia hooks + + def return_appearance(self, looker, **kwargs): + """ Could be modified per state. We generally don't worry about the + contents of the object by default. + + """ + # accept a custom desc + desc = kwargs.get("desc", self.db.desc) + + if kwargs.get('unfocused', False): + # use the shorter description + focused = "" + desc = self.get_short_desc(desc) + helptxt = "" + else: + focused = " |g(examining |G- use '|gex|G' again to look away. See also '|ghelp|G')|n" + helptxt = kwargs.get("helptxt", f"\n\n({self.get_help(looker)})") + + obj, pos = self.get_position(looker) + pos = (f" |w({self.position_prep_map[pos]} on " + f"{obj.get_display_name(looker)})" if obj else "") + + return f" ~~ |y{self.get_display_name(looker)}|n{focused}{pos}|n ~~\n\n{desc}{helptxt}" + + +class Feelable(EvscaperoomObject): + """ + Any object that you can feel the surface of. + + """ + def at_focus_feel(self, caller, **kwargs): + self.msg_char(caller, f"You feel *{self.key}.") + + +class Listenable(EvscaperoomObject): + """ + Any object one can listen to. + + """ + def at_focus_listen(self, caller, **kwargs): + self.msg_char(caller, f"You listen to *{self.key}") + + +class Smellable(EvscaperoomObject): + """ + Any object you can smell. + + """ + def at_focus_smell(self, caller, **kwargs): + self.msg_char(caller, f"You smell *{self.key}.") + + +class Rotatable(EvscaperoomObject): + """ + Any object that you can lift up and look at from different angles + + """ + rotate_flag = "rotatable" + start_rotatable = True + + def at_object_creation(self): + super().at_object_creation() + + if self.start_rotatable: + self.set_flag("rotatable") + + def at_focus_rotate(self, caller, **kwargs): + + if self.check_flag("rotatable"): + self.at_rotate(caller) + else: + self.at_cannot_rotate(caller) + at_focus_turn = at_focus_rotate + + def at_rotate(self, caller): + self.msg_char(caller, f"You turn *{self.key} around.") + + def at_cannot_rotate(self, caller): + self.msg_char(caller, f"You cannot rotate this.") + + +class Openable(EvscaperoomObject): + """ + Any object that you can open/close. It's lockable with + a flag. + + """ + # this flag must be set for item to open. None for unlocked. + unlock_flag = "unlocked" + open_flag = "open" + # start this item in the opened/unlocked state + start_open = False + + def at_object_creation(self): + super().at_object_creation() + if self.start_open: + self.set_flag(self.unlock_flag) + self.set_flag(self.open_flag) + + def at_focus_open(self, caller, **kwargs): + if self.check_flag(self.open_flag): + self.at_already_open(caller) + elif self.unlock_flag is None or self.check_flag(self.unlock_flag): + self.set_flag(self.open_flag) + self.at_open(caller) + else: + self.at_locked(caller) + + def at_focus_close(self, caller, **kwargs): + if not self.check_flag(self.open_flag): + self.at_already_closed(caller) + else: + self.unset_flag(self.open_flag) + self.at_close(caller) + + def at_open(self, caller): + self.msg_char(caller, f"You open *{self.key}") + + def at_already_open(self, caller): + self.msg_char(caller, f"{self.key.capitalize()} is already open.") + + def at_locked(self, caller): + self.msg_char(caller, f"{self.key.capitalize()} won't open.") + + def at_close(self, caller): + self.msg_char(caller, f"You close *{self.key}.") + + def at_already_closed(self, caller): + self.msg_char(caller, f"{self.key.capitalize()} is already closed.") + + +class Readable(EvscaperoomObject): + """ + Any object that you can read from. This is controlled + from a flag. + + """ + # this must be set to be able to read. None to + # always be able to read. + + read_flag = "readable" + start_readable = True + + def at_object_creation(self): + super().at_object_creation() + if self.start_readable: + self.set_flag(self.read_flag) + + def at_focus_read(self, caller, **kwargs): + + if self.read_flag is None or self.check_flag(self.read_flag): + self.at_read(caller) + else: + self.at_cannot_read(caller) + + def at_read(self, caller, *args, **kwargs): + self.msg_char(caller, f"You read from *{self.key}.") + + def at_cannot_read(self, caller, *args, **kwargs): + self.msg_char(caller, "You cannot understand a thing!") + + +class IndexReadable(Readable): + """ + Any object for which you need to specify a key/index to get a given result + back. For example a lexicon or book where you enter a topic or a page + number to see what's to be read on that page. + """ + + # keys should be lower-key + index = { + "page1": "This is page1", + "page2": "This is page2", + "page two": "page2" # alias + } + + def at_focus_read(self, caller, **kwargs): + + topic = kwargs.get("args").strip().lower() + + entry = self.index.get(topic, None) + + if entry is None or not self.check_flag(self.read_flag): + self.at_cannot_read(caller, topic) + else: + if entry in self.index: + # an alias-reroute + entry = self.index[entry] + self.at_read(caller, topic, entry) + + def get_cmd_signatures(self): + txt = (f"You don't have the time to read this from beginning to end. " + "Use *read to look up something in particular.") + return [], txt + + def at_cannot_read(self, caller, topic, *args, **kwargs): + self.msg_char(caller, f"Cannot find an entry on '{topic}'.") + + def at_read(self, caller, topic, entry, *args, **kwargs): + self.msg_char(caller, f"You read about '{topic}':\n{entry.strip()}") + + +class Movable(EvscaperoomObject): + """ + Any object that can be moved from one place to another + or in one direction or another. + + Once moved to a given position, the object's state will + change. + + """ + # these are the possible locations (or directions) to move to + # name: callable + move_positions = {"left": "at_left", + "right": "at_right"} + start_position = "left" + + def at_object_creation(self): + super().at_object_creation() + self.db.position = self.start_position + + def get_cmd_signatures(self): + txt = "Looks like you can {callsigns}." + return ["move", "push", "shove left/right"], txt + + def at_focus_move(self, caller, **kwargs): + pos = self.parse(kwargs['args']) + callfunc_name = self.move_positions.get(pos) + + if callfunc_name: + if self.db.position == pos: + self.at_already_moved(caller, pos) + else: + self.db.position = pos + getattr(self, callfunc_name)(caller) + else: + self.at_cannot_move(caller) + + at_focus_shove = at_focus_move + at_focus_push = at_focus_move + + def at_cannot_move(self, caller): + self.msg_char(caller, "That does not work.") + + def at_already_moved(self, caller, position): + self.msg_char(caller, f"You already moved *{self.key} to the {position}.") + + def at_left(self, caller): + self.msg_char(caller, f"You move *{self.key} left") + + def at_right(self, caller): + self.msg_char(caller, f"You move *{self.key} right") + + +class BaseConsumable(EvscaperoomObject): + """ + Any object that is consumable in some way. This acts as an + abstract parent. + + This sets a flag that + is unique for each person consuming, allowing it to e.g. only + be consumed once (don't support multi-uses here, that's left for + a custom object if needed). + + """ + consume_flag = "consume" + # may only consume once + one_consume_only = True + + def handle_consume(self, caller, action, **kwargs): + """ + Wrap this by the at_focus method + """ + if self.one_consume_only and self.has_consumed(caller): + self.at_already_consumed(caller, action) + else: + self.has_consumed(caller, True) + self.at_consume(caller, action) + + def has_consumed(self, caller, setflag=False): + "Check if caller already consumed at least once" + flag = f"{self.consume_flag}#{caller.id}" + if setflag: + self.set_flag(flag) + else: + return self.check_flag(flag) + + def at_consume(self, caller, action): + if hasattr(self, f"at_{action}"): + getattr(self, f"at_{action}")(caller) + else: + self.msg_char(caller, f"You {action} *{self.key}.") + + def at_already_consumed(self, caller, action): + self.msg_char(caller, f"You can't {action} any more.") + + +class Edible(BaseConsumable): + """ + Any object specifically possible to eat. + + """ + consume_flag = "eat" + + def at_focus_eat(self, caller, **kwargs): + super().handle_consume(caller, "eat", **kwargs) + + +class Drinkable(BaseConsumable): + """ + Any object specifically possible to drink. + + """ + consume_flag = "drink" + + def at_focus_drink(self, caller, **kwargs): + super().handle_consume(caller, "drink", **kwargs) + + def at_focus_sip(self, caller, **kwargs): + super().handle_consume(caller, "sip", **kwargs) + + def at_consume(self, caller, action): + self.msg_char(caller, f"You {action} from *{self.key}.") + + def at_already_consumed(self, caller, action): + self.msg_char(caller, f"You can't drink any more.") + + +class BaseApplicable(EvscaperoomObject): + """ + Any object that can be applied/inserted/used on another object in some way. + This acts an an abstract base class. + + """ + # the target object this is to be used with must + # have this flag. It'll likely be unique to this + # object combination. + target_flag = "applicable" + + def handle_apply(self, caller, action, **kwargs): + """ + Wrap this with the at_focus methods in the child classes + + """ + args = self.parse(kwargs['args']) + if not args: + self.msg_char(caller, "You need to specify a target.") + return + obj = caller.search(args) + if not obj: + return + try: + can_apply = obj.check_flag(self.target_flag) + except AttributeError: + can_apply = False + if can_apply: + self.at_apply(caller, action, obj) + else: + self.at_cannot_apply(caller, action, obj) + + def at_apply(self, caller, action, obj): + self.msg_char(caller, f"You {action} *{self.key} to {obj.key}.") + + def at_cannot_apply(self, caller, action, obj): + self.msg_char(caller, f"You cannot {action} *{self.key} to {obj.key}.") + + +class Usable(BaseApplicable): + """ + Any object that can be used with another object. + + """ + target_flag = "usable" + + def at_focus_use(self, caller, **kwargs): + super().handle_apply(caller, "use", **kwargs) + + def at_apply(self, caller, action, obj): + self.msg_char(caller, f"You {action} *{self.key} with {obj.key}") + + def at_cannot_apply(self, caller, action, obj): + self.msg_char(caller, f"You cannot {action} *{self.key} with {obj.key}.") + + +class Insertable(BaseApplicable): + """ + Any object that can be inserted into another object. + + This would cover a key, for example. + + """ + # this would likely be a custom name + target_flag = "insertable" + + def at_focus_insert(self, caller, **kwargs): + super().handle_apply(caller, "insert", **kwargs) + + def at_apply(self, caller, action, obj): + self.msg_char(caller, f"You {action} *{self.key} in {obj.key}.") + + def get_cmd_signatures(self): + txt = "You can use this object to {callsigns}" + return ["insert in "], txt + + def at_cannot_apply(self, caller, action, obj): + self.msg_char(caller, f"You cannot {action} *{self.key} in {obj.key}.") + + +class Combinable(BaseApplicable): + """ + Any object that combines with another object to create + a new one. + + """ + # the other object must have this flag to be able to be combined + # (this is likely unique for a given combination) + target_flag = "combinable" + # create-dict to pass into the create_object for the + # new "combined" object. + new_create_dict = { + "typeclass": "evscaperoom.objects.Combinable", + "key": "sword", + "aliases": ["combined"]} + # if set, destroy the two components used to make the new one + destroy_components = True + + def at_focus_combine(self, caller, **kwargs): + super().at_focus_apply(caller, **kwargs) + + def get_cmd_signatures(self): + txt = "It looks like this should work: {callsigns}" + return ["combine "], txt + + def at_cannot_apply(self, caller, action, obj): + self.msg_char(caller, f"You cannot {action} *{self.key} with {obj.key}.") + + def at_apply(self, caller, action, other_obj): + create_dict = self.new_create_dict + if "location" not in create_dict: + create_dict['location'] = self.location + new_obj = create_evscaperoom_object(**create_dict) + if new_obj and self.destroy_components: + self.msg_char(caller, + f"You combine *{self.key} with {other_obj.key} to make {new_obj.key}!") + other_obj.delete() + self.delete() + + +class Mixable(EvscaperoomObject): + """ + Any object into which you can mix ingredients (such as when + mixing a potion). This offers no actions on its own, instead + the ingredients should be 'used' with this object in order + mix, calling at_mix when they do. + """ + # ingredients can check for this before they allow to mix at all + mixer_flag = "mixer" + # ingredients must have these flags and this order + ingredient_recipe = [ + "ingredient1", + "ingredient2", + "ingredient3" + ] + + def at_object_creation(self): + super().at_object_creation() + self.set_flag(self.mixer_flag) + # this holds the ingredients as they are added + self.db.ingredients = [] + + def check_mixture(self): + "check so mixture is correct, returning True/False." + ingredients = list(self.db.ingredients) + for iflag, flag in enumerate(self.ingredient_recipe): + try: + if not ingredients[iflag].check_flag(flag): + return False + except (IndexError, AttributeError): + return False + # we only get here if all ingredients have the right flags in the right + # order + return True + + def handle_mix(self, caller, ingredient, **kwargs): + """ + Add ingredient object to mixture. + + Called by the mixing ingredient. We assume the ingredient has already + checked to make sure they allow themselves to be mixed into an object + with this mixer_flag. + + """ + self.db.ingredients.append(ingredient) + # normal mix + self.at_mix(caller, ingredient, **kwargs) + + if len(self.db.ingredients) >= len(self.ingredient_recipe): + # we have enough, check if it matches recipe + + if self.check_mixture(): + self.at_mix_success(caller, ingredient, **kwargs) + else: + self.room.log(f"{self.name} mix failure: Tried {' + '.join([ing.key for ing in self.db.ingredients if ing])}") + self.db.ingredients = [] + self.at_mix_failure(caller, ingredient, **kwargs) + + def at_mix(self, caller, ingredient, **kwargs): + self.msg_room(caller, f"~You ~mix {ingredient.key} into *{self.key}.") + + def at_mix_failure(self, caller, ingredient, **kwargs): + self.msg_room(caller, f"This mix doesn't work. ~You ~clean and start over.") + + def at_mix_success(self, caller, ingredient, **kwargs): + self.msg_room(caller, f"~You successfully ~complete the mix!") + + +class HasButtons(EvscaperoomObject): + """ + Any object with buttons to push/press + + """ + # mapping keys/aliases to calling method + buttons = {'green button': "at_green_button", + 'green': "at_green_button", + 'red button': "at_red_button", + 'red': "at_red_button"} + + def get_cmd_signatures(self): + helptxt = ("It looks like you should be able to operate " + f"*{self.key} by means of " + "{callsigns}.") + return ["push", "press red/green button"], helptxt + + def at_focus_press(self, caller, **kwargs): + arg = self.parse(kwargs['args']) + callfunc_name = self.buttons.get(arg) + if callfunc_name: + getattr(self, callfunc_name)(caller) + else: + self.at_nomatch(caller) + at_focus_push = at_focus_press + + def at_nomatch(self, caller): + self.msg_char(caller, "That does not seem right.") + + def at_green_button(self, caller): + self.msg_char(caller, "You press the green button.") + + def at_red_button(self, caller): + self.msg_char(caller, "You press the red button.") + + +class CodeInput(EvscaperoomObject): + """ + Any object where you can enter a code of some sort + to have an effect happen. + + """ + # the code of this + code = "PASSWORD" + code_hint = "eight letters A-Z" + case_insensitive = True + # code locked no matter what is input + infinitely_locked = False + + def at_focus_code(self, caller, **kwargs): + + args = self.parse(kwargs['args'].strip()) + + if not args: + self.at_no_code(caller) + return + if self.infinitely_locked: + code_correct = False + elif self.case_insensitive: + code_correct = args.upper() == self.code.upper() + else: + code_correct = args == self.code + + if code_correct: + self.at_code_correct(caller, args) + else: + self.at_code_incorrect(caller, args) + + def get_cmd_signatures(self): + helptxt = "Looks like you need to use {callsigns}." + return ["code "], helptxt + + def at_no_code(self, caller): + self.msg_char(caller, f"Looks like you need to enter |w{self.code_hint}|n.") + + def at_code_correct(self, caller, code_tried): + self.msg_char(caller, "That's the right code!") + + def at_code_incorrect(self, caller, code_tried): + self.msg_char(caller, f"That's not the right code (need {self.code_hint}).") + + +class BasePositionable(EvscaperoomObject): + """ + Any object a character can be positioned on. This is meant as an + abstract parent. + + This is a little special since a char can only have one position at a + time and must therefore be aware of the other 'positional' actions + any object may support (otherwise you may end up sitting/standing/etc on + more than one object at once!) + + We set a Attribute (obj, position) on the caller to indicate that + they have a position on an object. This is necessary so as to not have + the caller sit on more than one sittable object at a time, for example. The + 'positions' Attribute on this object holds a mapping of who is sitting + lying etc on this object. We don't add a limit to how many chars could + have a position on an object - it's not realistic, but this goes with the + philosophy that one character should not be able to block others if they go + inactive etc. + + This state is also tied to the general 'stand' command, which should return + the player to the normal standing state regardless of if they focus on this + object or not. + + """ + def at_object_creation(self): + super().at_object_creation() + # mapping {object: position}. + self.db.positions = {} + + def handle_position(self, caller, new_pos, **kwargs): + """ + Wrap this with the at_focus_ method of the child class. + + """ + old_obj, old_pos = self.get_position(caller) + if old_obj: + if old_obj is self: + if old_pos == new_pos: + self.at_again_position(caller, new_pos) + else: + self.set_position(caller, new_pos) + self.at_position(caller, new_pos) + else: + self.at_cannot_position(caller, new_pos, old_obj, old_pos) + else: + self.set_position(caller, new_pos) + self.at_position(caller, new_pos) + + def at_cannot_position(self, caller, position, old_obj, old_pos): + self.msg_char(caller, f"You can't; you are currently {self.position_prep_map[old_pos]} on *{old_obj.key} " + "(better |wstand|n first).") + + def at_again_position(self, caller, position): + self.msg_char(caller, f"But you are already {self.position_prep_map[position]} on *{self.key}?") + + def at_position(self, caller, position): + self.msg_room(caller, f"~You ~{position} on *{self.key}.") + + +class Sittable(BasePositionable): + """ + Any object you can sit on. + + """ + + def at_focus_sit(self, caller, **kwargs): + super().handle_position(caller, "sit", **kwargs) + + +class Liable(BasePositionable): + """ + Any object you can lie down on. + + """ + def at_focus_lie(self, caller, **kwargs): + super().handle_position(caller, "lie", **kwargs) + + +class Kneelable(BasePositionable): + """ + Any object you can kneel on. + + """ + def at_focus_kneel(self, caller, **kwargs): + super().handle_position(caller, "kneel", **kwargs) + + +class Climbable(BasePositionable): + """ + Any object you can climb up to stand on. We name this + 'climb' so as to not collide with the general 'stand' + command, which resets your position. + + """ + def at_focus_climb(self, caller, **kwargs): + super().handle_position(caller, "climb", **kwargs) + + +class Positionable(Sittable, Liable, Kneelable, Climbable): + """ + An object on which you can position yourself in one of the + supported ways (sit, lie, kneel or climb) + + """ + def get_cmd_signatures(self): + txt = "It looks like you can {callsigns} on it." + return ["sit", "lie", "kneel", "climb"], txt diff --git a/evennia/contrib/evscaperoom/room.py b/evennia/contrib/evscaperoom/room.py new file mode 100644 index 0000000000..10dd2b6a80 --- /dev/null +++ b/evennia/contrib/evscaperoom/room.py @@ -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}" diff --git a/evennia/contrib/evscaperoom/scripts.py b/evennia/contrib/evscaperoom/scripts.py new file mode 100644 index 0000000000..34960d590a --- /dev/null +++ b/evennia/contrib/evscaperoom/scripts.py @@ -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() diff --git a/evennia/contrib/evscaperoom/state.py b/evennia/contrib/evscaperoom/state.py new file mode 100644 index 0000000000..64c666307e --- /dev/null +++ b/evennia/contrib/evscaperoom/state.py @@ -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 diff --git a/evennia/contrib/evscaperoom/states/README.md b/evennia/contrib/evscaperoom/states/README.md new file mode 100644 index 0000000000..8547fb9a71 --- /dev/null +++ b/evennia/contrib/evscaperoom/states/README.md @@ -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_.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`. diff --git a/evennia/contrib/evscaperoom/states/state_001_start.py b/evennia/contrib/evscaperoom/states/state_001_start.py new file mode 100644 index 0000000000..e35de01ba9 --- /dev/null +++ b/evennia/contrib/evscaperoom/states/state_001_start.py @@ -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() diff --git a/evennia/contrib/evscaperoom/tests.py b/evennia/contrib/evscaperoom/tests.py new file mode 100644 index 0000000000..ebee45dd58 --- /dev/null +++ b/evennia/contrib/evscaperoom/tests.py @@ -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) diff --git a/evennia/contrib/evscaperoom/utils.py b/evennia/contrib/evscaperoom/utils.py new file mode 100644 index 0000000000..d9c0e3ee2d --- /dev/null +++ b/evennia/contrib/evscaperoom/utils.py @@ -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