diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cecd0de21..a05d65fdc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ # Sept 2017: Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. -Info on what changed and how to migrat is found here: +Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ ## Feb 2017: diff --git a/Dockerfile b/Dockerfile index 27af6b2a3a..4ca00d254b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,11 @@ # install `docker` (http://docker.com) # # Usage: -# cd to a folder where you want your game data to be (or where it already is). +# cd to a folder where you want your game data to be (or where it already is). # # docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia -# -# (If your OS does not support $PWD, replace it with the full path to your current +# +# (If your OS does not support $PWD, replace it with the full path to your current # folder). # # You will end up in a shell where the `evennia` command is available. From here you @@ -30,10 +30,10 @@ RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jp ADD . /usr/src/evennia # install dependencies -RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org +RUN pip install --upgrade pip && pip install /usr/src/evennia --trusted-host pypi.python.org # add the game source when rebuilding a new docker image from inside -# a game dir +# a game dir ONBUILD ADD . /usr/src/game # make the game source hierarchy persistent with a named volume. @@ -48,7 +48,7 @@ WORKDIR /usr/src/game ENV PS1 "evennia|docker \w $ " # startup a shell when we start the container -ENTRYPOINT ["bash"] +ENTRYPOINT bash -c "source /usr/src/evennia/bin/unix/evennia-docker-start.sh" # expose the telnet, webserver and websocket client ports EXPOSE 4000 4001 4005 diff --git a/bin/unix/evennia-docker-start.sh b/bin/unix/evennia-docker-start.sh new file mode 100644 index 0000000000..270f6ec627 --- /dev/null +++ b/bin/unix/evennia-docker-start.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +# called by the Dockerfile to start the server in docker mode + +# remove leftover .pid files (such as from when dropping the container) +rm /usr/src/game/server/*.pid >& /dev/null || true + +# start evennia server; log to server.log but also output to stdout so it can +# be viewed with docker-compose logs +exec 3>&1; evennia start 2>&1 1>&3 | tee /usr/src/game/server/logs/server.log; exec 3>&- + +# start a shell to keep the container running +bash diff --git a/evennia/VERSION.txt b/evennia/VERSION.txt index faef31a435..f8d71478f5 100644 --- a/evennia/VERSION.txt +++ b/evennia/VERSION.txt @@ -1 +1 @@ -0.7.0 +0.8.0-dev diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 48d03d8dc8..ff77eb0b90 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -771,7 +771,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): elif _MULTISESSION_MODE in (2, 3): # In this mode we by default end up at a character selection # screen. We execute look on the account. - # we make sure to clean up the _playable_characers list in case + # we make sure to clean up the _playable_characters list in case # any was deleted in the interim. self.db._playable_characters = [char for char in self.db._playable_characters if char] self.msg(self.at_look(target=self.db._playable_characters, diff --git a/evennia/commands/command.py b/evennia/commands/command.py index 48a4b132da..17902b3602 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -297,7 +297,7 @@ class Command(with_metaclass(CommandMeta, object)): Args: srcobj (Object): Object trying to gain permission access_type (str, optional): The lock type to check. - default (bool, optional): The fallbacl result if no lock + default (bool, optional): The fallback result if no lock of matching `access_type` is found on this Command. """ diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index 9a4d2d330c..40805f7465 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -168,7 +168,7 @@ class CmdCharCreate(COMMAND_DEFAULT_CLASS): if desc: new_character.db.desc = desc elif not new_character.db.desc: - new_character.db.desc = "This is an Account." + new_character.db.desc = "This is a character." self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character." % (new_character.key, new_character.key)) @@ -455,7 +455,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): Usage: @option[/save] [name = value] - Switch: + Switches: save - Save the current option settings for future logins. clear - Clear the saved options. @@ -467,6 +467,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): """ key = "@option" aliases = "@options" + switch_options = ("save", "clear") locks = "cmd:all()" # this is used by the parent @@ -650,6 +651,7 @@ class CmdQuit(COMMAND_DEFAULT_CLASS): game. Use the /all switch to disconnect from all sessions. """ key = "@quit" + switch_options = ("all",) locks = "cmd:all()" # this is used by the parent diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py index 8b694ffd8f..4e517b77f9 100644 --- a/evennia/commands/default/admin.py +++ b/evennia/commands/default/admin.py @@ -36,6 +36,7 @@ class CmdBoot(COMMAND_DEFAULT_CLASS): """ key = "@boot" + switch_options = ("quiet", "sid") locks = "cmd:perm(boot) or perm(Admin)" help_category = "Admin" @@ -265,6 +266,7 @@ class CmdDelAccount(COMMAND_DEFAULT_CLASS): """ key = "@delaccount" + switch_options = ("delobj",) locks = "cmd:perm(delaccount) or perm(Developer)" help_category = "Admin" @@ -329,9 +331,9 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): @pemit [, , ... =] Switches: - room : limit emits to rooms only (default) - accounts : limit emits to accounts only - contents : send to the contents of matched objects too + room - limit emits to rooms only (default) + accounts - limit emits to accounts only + contents - send to the contents of matched objects too Emits a message to the selected objects or to your immediate surroundings. If the object is a room, @@ -341,6 +343,7 @@ class CmdEmit(COMMAND_DEFAULT_CLASS): """ key = "@emit" aliases = ["@pemit", "@remit"] + switch_options = ("room", "accounts", "contents") locks = "cmd:perm(emit) or perm(Builder)" help_category = "Admin" @@ -442,14 +445,15 @@ class CmdPerm(COMMAND_DEFAULT_CLASS): @perm[/switch] * [= [,,...]] Switches: - del : delete the given permission from or . - account : set permission on an account (same as adding * to name) + del - delete the given permission from or . + account - set permission on an account (same as adding * to name) This command sets/clears individual permission strings on an object or account. If no permission is given, list all permissions on . """ key = "@perm" aliases = "@setperm" + switch_options = ("del", "account") locks = "cmd:perm(perm) or perm(Developer)" help_category = "Admin" diff --git a/evennia/commands/default/batchprocess.py b/evennia/commands/default/batchprocess.py index f0b117a816..5970d35887 100644 --- a/evennia/commands/default/batchprocess.py +++ b/evennia/commands/default/batchprocess.py @@ -237,6 +237,7 @@ class CmdBatchCommands(_COMMAND_DEFAULT_CLASS): """ key = "@batchcommands" aliases = ["@batchcommand", "@batchcmd"] + switch_options = ("interactive",) locks = "cmd:perm(batchcommands) or perm(Developer)" help_category = "Building" @@ -347,6 +348,7 @@ class CmdBatchCode(_COMMAND_DEFAULT_CLASS): """ key = "@batchcode" aliases = ["@batchcodes"] + switch_options = ("interactive", "debug") locks = "cmd:superuser()" help_category = "Building" diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 0afeea8fe5..702f2e241a 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -106,9 +106,15 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): Usage: @alias [= [alias[,alias,alias,...]]] @alias = + @alias/category = [alias[,alias,...]: + + Switches: + category - requires ending input with :category, to store the + given aliases with the given category. Assigns aliases to an object so it can be referenced by more - than one name. Assign empty to remove all aliases from object. + than one name. Assign empty to remove all aliases from object. If + assigning a category, all aliases given will be using this category. Observe that this is not the same thing as personal aliases created with the 'nick' command! Aliases set with @alias are @@ -118,6 +124,7 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): key = "@alias" aliases = "@setobjalias" + switch_options = ("category",) locks = "cmd:perm(setobjalias) or perm(Builder)" help_category = "Building" @@ -138,9 +145,12 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): return if self.rhs is None: # no =, so we just list aliases on object. - aliases = obj.aliases.all() + aliases = obj.aliases.all(return_key_and_category=True) if aliases: - caller.msg("Aliases for '%s': %s" % (obj.get_display_name(caller), ", ".join(aliases))) + caller.msg("Aliases for %s: %s" % ( + obj.get_display_name(caller), + ", ".join("'%s'%s" % (alias, "" if category is None else "[category:'%s']" % category) + for (alias, category) in aliases))) else: caller.msg("No aliases exist for '%s'." % obj.get_display_name(caller)) return @@ -159,17 +169,27 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): caller.msg("No aliases to clear.") return + category = None + if "category" in self.switches: + if ":" in self.rhs: + rhs, category = self.rhs.rsplit(':', 1) + category = category.strip() + else: + caller.msg("If specifying the /category switch, the category must be given " + "as :category at the end.") + else: + rhs = self.rhs + # merge the old and new aliases (if any) - old_aliases = obj.aliases.all() - new_aliases = [alias.strip().lower() for alias in self.rhs.split(',') - if alias.strip()] + old_aliases = obj.aliases.get(category=category, return_list=True) + new_aliases = [alias.strip().lower() for alias in rhs.split(',') if alias.strip()] # make the aliases only appear once old_aliases.extend(new_aliases) aliases = list(set(old_aliases)) # save back to object. - obj.aliases.add(aliases) + obj.aliases.add(aliases, category=category) # we need to trigger this here, since this will force # (default) Exits to rebuild their Exit commands with the new @@ -177,7 +197,8 @@ class CmdSetObjAlias(COMMAND_DEFAULT_CLASS): obj.at_cmdset_get(force_init=True) # report all aliases on the object - caller.msg("Alias(es) for '%s' set to %s." % (obj.get_display_name(caller), str(obj.aliases))) + caller.msg("Alias(es) for '%s' set to '%s'%s." % (obj.get_display_name(caller), + str(obj.aliases), " (category: '%s')" % category if category else "")) class CmdCopy(ObjManipCommand): @@ -198,6 +219,7 @@ class CmdCopy(ObjManipCommand): """ key = "@copy" + switch_options = ("reset",) locks = "cmd:perm(copy) or perm(Builder)" help_category = "Building" @@ -279,6 +301,7 @@ class CmdCpAttr(ObjManipCommand): If you don't supply a source object, yourself is used. """ key = "@cpattr" + switch_options = ("move",) locks = "cmd:perm(cpattr) or perm(Builder)" help_category = "Building" @@ -420,6 +443,7 @@ class CmdMvAttr(ObjManipCommand): object. If you don't supply a source object, yourself is used. """ key = "@mvattr" + switch_options = ("copy",) locks = "cmd:perm(mvattr) or perm(Builder)" help_category = "Building" @@ -468,6 +492,7 @@ class CmdCreate(ObjManipCommand): """ key = "@create" + switch_options = ("drop",) locks = "cmd:perm(create) or perm(Builder)" help_category = "Building" @@ -553,6 +578,7 @@ class CmdDesc(COMMAND_DEFAULT_CLASS): """ key = "@desc" aliases = "@describe" + switch_options = ("edit",) locks = "cmd:perm(desc) or perm(Builder)" help_category = "Building" @@ -614,11 +640,11 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): Usage: @destroy[/switches] [obj, obj2, obj3, [dbref-dbref], ...] - switches: + Switches: override - The @destroy command will usually avoid accidentally destroying account objects. This switch overrides this safety. force - destroy without confirmation. - examples: + Examples: @destroy house, roof, door, 44-78 @destroy 5-10, flower, 45 @destroy/force north @@ -631,6 +657,7 @@ class CmdDestroy(COMMAND_DEFAULT_CLASS): key = "@destroy" aliases = ["@delete", "@del"] + switch_options = ("override", "force") locks = "cmd:perm(destroy) or perm(Builder)" help_category = "Building" @@ -754,6 +781,7 @@ class CmdDig(ObjManipCommand): would be 'north;no;n'. """ key = "@dig" + switch_options = ("teleport",) locks = "cmd:perm(dig) or perm(Builder)" help_category = "Building" @@ -863,7 +891,7 @@ class CmdDig(ObjManipCommand): new_back_exit.dbref, alias_string) caller.msg("%s%s%s" % (room_string, exit_to_string, exit_back_string)) - if new_room and ('teleport' in self.switches or "tel" in self.switches): + if new_room and 'teleport' in self.switches: caller.move_to(new_room) @@ -896,6 +924,7 @@ class CmdTunnel(COMMAND_DEFAULT_CLASS): key = "@tunnel" aliases = ["@tun"] + switch_options = ("oneway", "tel") locks = "cmd: perm(tunnel) or perm(Builder)" help_category = "Building" @@ -1458,6 +1487,13 @@ class CmdSetAttribute(ObjManipCommand): Switch: edit: Open the line editor (string values only) + script: If we're trying to set an attribute on a script + channel: If we're trying to set an attribute on a channel + account: If we're trying to set an attribute on an account + room: Setting an attribute on a room (global search) + exit: Setting an attribute on an exit (global search) + char: Setting an attribute on a character (global search) + character: Alias for char, as above. Sets attributes on objects. The second form clears a previously set attribute while the last form @@ -1558,6 +1594,38 @@ class CmdSetAttribute(ObjManipCommand): # start the editor EvEditor(self.caller, load, save, key="%s/%s" % (obj, attr)) + def search_for_obj(self, objname): + """ + Searches for an object matching objname. The object may be of different typeclasses. + Args: + objname: Name of the object we're looking for + + Returns: + A typeclassed object, or None if nothing is found. + """ + from evennia.utils.utils import variable_from_module + _AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1)) + caller = self.caller + if objname.startswith('*') or "account" in self.switches: + found_obj = caller.search_account(objname.lstrip('*')) + elif "script" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_script(objname), caller) + elif "channel" in self.switches: + found_obj = _AT_SEARCH_RESULT(search.search_channel(objname), caller) + else: + global_search = True + if "char" in self.switches or "character" in self.switches: + typeclass = settings.BASE_CHARACTER_TYPECLASS + elif "room" in self.switches: + typeclass = settings.BASE_ROOM_TYPECLASS + elif "exit" in self.switches: + typeclass = settings.BASE_EXIT_TYPECLASS + else: + global_search = False + typeclass = None + found_obj = caller.search(objname, global_search=global_search, typeclass=typeclass) + return found_obj + def func(self): """Implement the set attribute - a limited form of @py.""" @@ -1571,10 +1639,7 @@ class CmdSetAttribute(ObjManipCommand): objname = self.lhs_objattr[0]['name'] attrs = self.lhs_objattr[0]['attrs'] - if objname.startswith('*'): - obj = caller.search_account(objname.lstrip('*')) - else: - obj = caller.search(objname) + obj = self.search_for_obj(objname) if not obj: return @@ -2202,12 +2267,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): Usage: @find[/switches] [= dbrefmin[-dbrefmax]] + @locate - this is a shorthand for using the /loc switch. Switches: room - only look for rooms (location=None) exit - only look for exits (destination!=None) char - only look for characters (BASE_CHARACTER_TYPECLASS) exact- only exact matches are returned. + loc - display object location if exists and match has one result Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2218,6 +2285,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" + switch_options = ("room", "exit", "char", "exact", "loc") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2230,6 +2298,9 @@ class CmdFind(COMMAND_DEFAULT_CLASS): caller.msg("Usage: @find [= low [-high]]") return + if "locate" in self.cmdstring: # Use option /loc as a default for @locate command alias + switches.append('loc') + searchstring = self.lhs low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id if self.rhs: @@ -2251,7 +2322,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): restrictions = "" if self.switches: - restrictions = ", %s" % (",".join(self.switches)) + restrictions = ", %s" % (", ".join(self.switches)) if is_dbref or is_account: @@ -2279,6 +2350,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: result = result[0] string += "\n|g %s - %s|n" % (result.get_display_name(caller), result.path) + if "loc" in self.switches and not is_account and result.location: + string += " (|wlocation|n: |g{}|n)".format(result.location.get_display_name(caller)) else: # Not an account/dbref search but a wider search; build a queryset. # Searchs for key and aliases @@ -2314,6 +2387,8 @@ class CmdFind(COMMAND_DEFAULT_CLASS): else: string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |g%s - %s|n" % (results[0].get_display_name(caller), results[0].path) + if "loc" in self.switches and nresults == 1 and results[0].location: + string += " (|wlocation|n: |g{}|n)".format(results[0].location.get_display_name(caller)) else: string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) string += "\n |RNo matches found for '%s'|n" % searchstring @@ -2327,7 +2402,7 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): teleport object to another location Usage: - @tel/switch [ =] + @tel/switch [ to||=] Examples: @tel Limbo @@ -2351,6 +2426,8 @@ class CmdTeleport(COMMAND_DEFAULT_CLASS): """ key = "@tel" aliases = "@teleport" + switch_options = ("quiet", "intoexit", "tonone", "loc") + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. locks = "cmd:perm(teleport) or perm(Builder)" help_category = "Building" @@ -2458,6 +2535,7 @@ class CmdScript(COMMAND_DEFAULT_CLASS): key = "@script" aliases = "@addscript" + switch_options = ("start", "stop") locks = "cmd:perm(script) or perm(Builder)" help_category = "Building" @@ -2557,6 +2635,7 @@ class CmdTag(COMMAND_DEFAULT_CLASS): key = "@tag" aliases = ["@tags"] + options = ("search", "del") locks = "cmd:perm(tag) or perm(Builder)" help_category = "Building" arg_regex = r"(/\w+?(\s|$))|\s|$" @@ -2698,6 +2777,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" + switch_options = ("noloc", ) locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index 4e357a2ce0..d7b887c017 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -11,7 +11,7 @@ command method rather than caller.msg(). from evennia.commands.cmdset import CmdSet from evennia.commands.default import help, comms, admin, system -from evennia.commands.default import building, account +from evennia.commands.default import building, account, general class AccountCmdSet(CmdSet): @@ -39,6 +39,9 @@ class AccountCmdSet(CmdSet): self.add(account.CmdColorTest()) self.add(account.CmdQuell()) + # nicks + self.add(general.CmdNick()) + # testing self.add(building.CmdExamine()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 37a4934d16..d9fe0b0d20 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -377,7 +377,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): Usage: @cboot[/quiet] = [:reason] - Switches: + Switch: quiet - don't notify the channel Kicks an account or object from a channel you control. @@ -385,6 +385,7 @@ class CmdCBoot(COMMAND_DEFAULT_CLASS): """ key = "@cboot" + switch_options = ("quiet",) locks = "cmd: not pperm(channel_banned)" help_category = "Comms" @@ -453,6 +454,7 @@ class CmdCemit(COMMAND_DEFAULT_CLASS): key = "@cemit" aliases = ["@cmsg"] + switch_options = ("sendername", "quiet") locks = "cmd: not pperm(channel_banned) and pperm(Player)" help_category = "Comms" @@ -683,6 +685,7 @@ class CmdPage(COMMAND_DEFAULT_CLASS): key = "page" aliases = ['tell'] + switch_options = ("last", "list") locks = "cmd:not pperm(page_banned)" help_category = "Comms" @@ -850,6 +853,7 @@ class CmdIRC2Chan(COMMAND_DEFAULT_CLASS): """ key = "@irc2chan" + switch_options = ("delete", "remove", "disconnect", "list", "ssl") locks = "cmd:serversetting(IRC_ENABLED) and pperm(Developer)" help_category = "Comms" @@ -1016,6 +1020,7 @@ class CmdRSS2Chan(COMMAND_DEFAULT_CLASS): """ key = "@rss2chan" + switch_options = ("disconnect", "remove", "list") locks = "cmd:serversetting(RSS_ENABLED) and pperm(Developer)" help_category = "Comms" diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index aef5309d32..d72a2006b9 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -88,8 +88,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): Switches: inputline - replace on the inputline (default) object - replace on object-lookup - account - replace on account-lookup - + account - replace on account-lookup list - show all defined aliases (also "nicks" works) delete - remove nick by index in /list clearall - clear all nicks @@ -118,7 +117,8 @@ class CmdNick(COMMAND_DEFAULT_CLASS): """ key = "nick" - aliases = ["nickname", "nicks", "alias"] + switch_options = ("inputline", "object", "account", "list", "delete", "clearall") + aliases = ["nickname", "nicks"] locks = "cmd:all()" def parse(self): @@ -143,7 +143,6 @@ class CmdNick(COMMAND_DEFAULT_CLASS): return re.sub(r"(\$[0-9]+|\*|\?|\[.+?\])", r"|Y\1|n", string) caller = self.caller - account = self.caller.account or caller switches = self.switches nicktypes = [switch for switch in switches if switch in ("object", "account", "inputline")] specified_nicktype = bool(nicktypes) @@ -151,7 +150,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): nicklist = (utils.make_iter(caller.nicks.get(category="inputline", return_obj=True) or []) + utils.make_iter(caller.nicks.get(category="object", return_obj=True) or []) + - utils.make_iter(account.nicks.get(category="account", return_obj=True) or [])) + utils.make_iter(caller.nicks.get(category="account", return_obj=True) or [])) if 'list' in switches or self.cmdstring in ("nicks", "@nicks"): @@ -174,29 +173,77 @@ class CmdNick(COMMAND_DEFAULT_CLASS): if 'delete' in switches or 'del' in switches: if not self.args or not self.lhs: - caller.msg("usage nick/delete #num ('nicks' for list)") + caller.msg("usage nick/delete or <#num> ('nicks' for list)") return # see if a number was given arg = self.args.lstrip("#") + oldnicks = [] if arg.isdigit(): # we are given a index in nicklist delindex = int(arg) if 0 < delindex <= len(nicklist): - oldnick = nicklist[delindex - 1] - _, _, old_nickstring, old_replstring = oldnick.value + oldnicks.append(nicklist[delindex - 1]) else: caller.msg("Not a valid nick index. See 'nicks' for a list.") return - nicktype = oldnick.category - nicktypestr = "%s-nick" % nicktype.capitalize() + else: + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + oldnicks.append(caller.nicks.get(arg, category=nicktype, return_obj=True)) - if nicktype == "account": - account.nicks.remove(old_nickstring, category=nicktype) - else: + oldnicks = [oldnick for oldnick in oldnicks if oldnick] + if oldnicks: + for oldnick in oldnicks: + nicktype = oldnick.category + nicktypestr = "%s-nick" % nicktype.capitalize() + _, _, old_nickstring, old_replstring = oldnick.value caller.nicks.remove(old_nickstring, category=nicktype) - caller.msg("%s removed: '|w%s|n' -> |w%s|n." % ( - nicktypestr, old_nickstring, old_replstring)) - return + caller.msg("%s removed: '|w%s|n' -> |w%s|n." % ( + nicktypestr, old_nickstring, old_replstring)) + else: + caller.msg("No matching nicks to remove.") + return + + if not self.rhs and self.lhs: + # check what a nick is set to + strings = [] + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + nicks = utils.make_iter(caller.nicks.get(category=nicktype, return_obj=True)) + for nick in nicks: + _, _, nick, repl = nick.value + if nick.startswith(self.lhs): + strings.append("{}-nick: '{}' -> '{}'".format( + nicktype.capitalize(), nick, repl)) + if strings: + caller.msg("\n".join(strings)) + else: + caller.msg("No nicks found matching '{}'".format(self.lhs)) + return + + if not self.rhs and self.lhs: + # check what a nick is set to + strings = [] + if not specified_nicktype: + nicktypes = ("object", "account", "inputline") + for nicktype in nicktypes: + if nicktype == "account": + obj = account + else: + obj = caller + nicks = utils.make_iter(obj.nicks.get(category=nicktype, return_obj=True)) + for nick in nicks: + _, _, nick, repl = nick.value + if nick.startswith(self.lhs): + strings.append("{}-nick: '{}' -> '{}'".format( + nicktype.capitalize(), nick, repl)) + if strings: + caller.msg("\n".join(strings)) + else: + caller.msg("No nicks found matching '{}'".format(self.lhs)) + return if not self.rhs and self.lhs: # check what a nick is set to @@ -237,16 +284,11 @@ class CmdNick(COMMAND_DEFAULT_CLASS): errstring = "" string = "" for nicktype in nicktypes: - if nicktype == "account": - obj = account - else: - obj = caller - nicktypestr = "%s-nick" % nicktype.capitalize() old_nickstring = None old_replstring = None - oldnick = obj.nicks.get(key=nickstring, category=nicktype, return_obj=True) + oldnick = caller.nicks.get(key=nickstring, category=nicktype, return_obj=True) if oldnick: _, _, old_nickstring, old_replstring = oldnick.value if replstring: @@ -261,7 +303,7 @@ class CmdNick(COMMAND_DEFAULT_CLASS): else: string += "\n%s '|w%s|n' mapped to '|w%s|n'." % (nicktypestr, nickstring, replstring) try: - obj.nicks.add(nickstring, replstring, category=nicktype) + caller.nicks.add(nickstring, replstring, category=nicktype) except NickTemplateInvalid: caller.msg("You must use the same $-markers both in the nick and in the replacement.") return @@ -337,13 +379,17 @@ class CmdGet(COMMAND_DEFAULT_CLASS): caller.msg("You can't get that.") return + # calling at_before_get hook method + if not obj.at_before_get(caller): + return + obj.move_to(caller, quiet=True) caller.msg("You pick up %s." % obj.name) caller.location.msg_contents("%s picks up %s." % (caller.name, obj.name), exclude=caller) - # calling hook method + # calling at_get hook method obj.at_get(caller) @@ -378,6 +424,10 @@ class CmdDrop(COMMAND_DEFAULT_CLASS): if not obj: return + # Call the object script's at_before_drop() method. + if not obj.at_before_drop(caller): + return + obj.move_to(caller.location, quiet=True) caller.msg("You drop %s." % (obj.name,)) caller.location.msg_contents("%s drops %s." % @@ -392,12 +442,13 @@ class CmdGive(COMMAND_DEFAULT_CLASS): give away something to someone Usage: - give = + give Gives an items from your inventory to another character, placing it in their inventory. """ key = "give" + rhs_split = ("=", " to ") # Prefer = delimiter, but allow " to " usage. locks = "cmd:all()" arg_regex = r"\s|$" @@ -420,6 +471,11 @@ class CmdGive(COMMAND_DEFAULT_CLASS): if not to_give.location == caller: caller.msg("You are not holding %s." % to_give.key) return + + # calling at_before_give hook method + if not to_give.at_before_give(caller, target): + return + # give object caller.msg("You give %s to %s." % (to_give.key, target.key)) to_give.move_to(target, quiet=True) @@ -496,7 +552,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS): Usage: whisper = - whisper , = , = Talk privately to one or more characters in your current location, without others in the room being informed. diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index ebce460748..d2011629c1 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -317,6 +317,7 @@ class CmdSetHelp(COMMAND_DEFAULT_CLASS): """ key = "@sethelp" + switch_options = ("edit", "replace", "append", "extend", "delete") locks = "cmd:perm(Helper)" help_category = "Building" diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 5d8d4b2890..349679f5bd 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -79,6 +79,13 @@ class MuxCommand(Command): it here). The rest of the command is stored in self.args, which can start with the switch indicator /. + Optional variables to aid in parsing, if set: + self.switch_options - (tuple of valid /switches expected by this + command (without the /)) + self.rhs_split - Alternate string delimiter or tuple of strings + to separate left/right hand sides. tuple form + gives priority split to first string delimiter. + This parser breaks self.args into its constituents and stores them in the following variables: self.switches = [list of /switches (without the /)] @@ -97,9 +104,18 @@ class MuxCommand(Command): """ raw = self.args args = raw.strip() + # Without explicitly setting these attributes, they assume default values: + if not hasattr(self, "switch_options"): + self.switch_options = None + if not hasattr(self, "rhs_split"): + self.rhs_split = "=" + if not hasattr(self, "account_caller"): + self.account_caller = False # split out switches - switches = [] + switches, delimiters = [], self.rhs_split + if self.switch_options: + self.switch_options = [opt.lower() for opt in self.switch_options] if args and len(args) > 1 and raw[0] == "/": # we have a switch, or a set of switches. These end with a space. switches = args[1:].split(None, 1) @@ -109,16 +125,50 @@ class MuxCommand(Command): else: args = "" switches = switches[0].split('/') + # If user-provides switches, parse them with parser switch options. + if switches and self.switch_options: + valid_switches, unused_switches, extra_switches = [], [], [] + for element in switches: + option_check = [opt for opt in self.switch_options if opt == element] + if not option_check: + option_check = [opt for opt in self.switch_options if opt.startswith(element)] + match_count = len(option_check) + if match_count > 1: + extra_switches.extend(option_check) # Either the option provided is ambiguous, + elif match_count == 1: + valid_switches.extend(option_check) # or it is a valid option abbreviation, + elif match_count == 0: + unused_switches.append(element) # or an extraneous option to be ignored. + if extra_switches: # User provided switches + self.msg('|g%s|n: |wAmbiguous switch supplied: Did you mean /|C%s|w?' % + (self.cmdstring, ' |nor /|C'.join(extra_switches))) + if unused_switches: + plural = '' if len(unused_switches) == 1 else 'es' + self.msg('|g%s|n: |wExtra switch%s "/|C%s|w" ignored.' % + (self.cmdstring, plural, '|n, /|C'.join(unused_switches))) + switches = valid_switches # Only include valid_switches in command function call arglist = [arg.strip() for arg in args.split()] # check for arg1, arg2, ... = argA, argB, ... constructs - lhs, rhs = args, None - lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] - if args and '=' in args: - lhs, rhs = [arg.strip() for arg in args.split('=', 1)] - lhslist = [arg.strip() for arg in lhs.split(',')] - rhslist = [arg.strip() for arg in rhs.split(',')] - + lhs, rhs = args.strip(), None + if lhs: + if delimiters and hasattr(delimiters, '__iter__'): # If delimiter is iterable, + best_split = delimiters[0] # (default to first delimiter) + for this_split in delimiters: # try each delimiter + if this_split in lhs: # to find first successful split + best_split = this_split # to be the best split. + break + else: + best_split = delimiters + # Parse to separate left into left/right sides using best_split delimiter string + if best_split in lhs: + lhs, rhs = lhs.split(best_split, 1) + # Trim user-injected whitespace + rhs = rhs.strip() if rhs is not None else None + lhs = lhs.strip() + # Further split left/right sides by comma delimiter + lhslist = [arg.strip() for arg in lhs.split(',')] if lhs is not None else "" + rhslist = [arg.strip() for arg in rhs.split(',')] if rhs is not None else "" # save to object properties: self.raw = raw self.switches = switches @@ -133,7 +183,7 @@ class MuxCommand(Command): # sure that self.caller is always the account if possible. We also create # a special property "character" for the puppeted object, if any. This # is convenient for commands defined on the Account only. - if hasattr(self, "account_caller") and self.account_caller: + if self.account_caller: if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): # caller is an Object/Character self.character = self.caller @@ -169,6 +219,8 @@ class MuxCommand(Command): string += "\nraw argument (self.raw): |w%s|n \n" % self.raw string += "cmd args (self.args): |w%s|n\n" % self.args string += "cmd switches (self.switches): |w%s|n\n" % self.switches + string += "cmd options (self.switch_options): |w%s|n\n" % self.switch_options + string += "cmd parse left/right using (self.rhs_split): |w%s|n\n" % self.rhs_split string += "space-separated arg list (self.arglist): |w%s|n\n" % self.arglist string += "lhs, left-hand side of '=' (self.lhs): |w%s|n\n" % self.lhs string += "lhs, comma separated (self.lhslist): |w%s|n\n" % self.lhslist @@ -193,18 +245,4 @@ class MuxAccountCommand(MuxCommand): character is actually attached to this Account and Session. """ - def parse(self): - """ - We run the parent parser as usual, then fix the result - """ - super(MuxAccountCommand, self).parse() - - if utils.inherits_from(self.caller, "evennia.objects.objects.DefaultObject"): - # caller is an Object/Character - self.character = self.caller - self.caller = self.caller.account - elif utils.inherits_from(self.caller, "evennia.accounts.accounts.DefaultAccount"): - # caller was already an Account - self.character = self.caller.get_puppet(self.session) - else: - self.character = None + account_caller = True # Using MuxAccountCommand explicitly defaults the caller to an account diff --git a/evennia/commands/default/system.py b/evennia/commands/default/system.py index fe5efa337d..c454de9aff 100644 --- a/evennia/commands/default/system.py +++ b/evennia/commands/default/system.py @@ -58,7 +58,7 @@ class CmdReload(COMMAND_DEFAULT_CLASS): if self.args: reason = "(Reason: %s) " % self.args.rstrip(".") SESSIONS.announce_all(" Server restart initiated %s..." % reason) - SESSIONS.server.shutdown(mode='reload') + SESSIONS.portal_restart_server() class CmdReset(COMMAND_DEFAULT_CLASS): @@ -91,7 +91,7 @@ class CmdReset(COMMAND_DEFAULT_CLASS): Reload the system. """ SESSIONS.announce_all(" Server resetting/restarting ...") - SESSIONS.server.shutdown(mode='reset') + SESSIONS.portal_reset_server() class CmdShutdown(COMMAND_DEFAULT_CLASS): @@ -119,7 +119,6 @@ class CmdShutdown(COMMAND_DEFAULT_CLASS): announcement += "%s\n" % self.args logger.log_info('Server shutdown by %s.' % self.caller.name) SESSIONS.announce_all(announcement) - SESSIONS.server.shutdown(mode='shutdown') SESSIONS.portal_shutdown() @@ -246,6 +245,7 @@ class CmdPy(COMMAND_DEFAULT_CLASS): """ key = "@py" aliases = ["!"] + switch_options = ("time", "edit") locks = "cmd:perm(py) or perm(Developer)" help_category = "System" @@ -329,6 +329,7 @@ class CmdScripts(COMMAND_DEFAULT_CLASS): """ key = "@scripts" aliases = ["@globalscript", "@listscripts"] + switch_options = ("start", "stop", "kill", "validate") locks = "cmd:perm(listscripts) or perm(Admin)" help_category = "System" @@ -522,6 +523,7 @@ class CmdService(COMMAND_DEFAULT_CLASS): key = "@service" aliases = ["@services"] + switch_options = ("list", "start", "stop", "delete") locks = "cmd:perm(service) or perm(Developer)" help_category = "System" @@ -673,7 +675,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): Usage: @server[/mem] - Switch: + Switches: mem - return only a string of the current memory usage flushmem - flush the idmapper cache @@ -704,6 +706,7 @@ class CmdServerLoad(COMMAND_DEFAULT_CLASS): """ key = "@server" aliases = ["@serverload", "@serverprocess"] + switch_options = ("mem", "flushmem") locks = "cmd:perm(list) or perm(Developer)" help_category = "System" diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 41654077a4..bf01ac3039 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -21,6 +21,7 @@ from mock import Mock, mock from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms +from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.command import Command, InterruptCommand from evennia.utils import ansi, utils from evennia.server.sessionhandler import SESSIONS @@ -72,7 +73,6 @@ class CommandTest(EvenniaTest): cmdobj.obj = obj or (caller if caller else self.char1) # test old_msg = receiver.msg - returned_msg = "" try: receiver.msg = Mock() cmdobj.at_pre_cmd() @@ -126,18 +126,48 @@ class TestGeneral(CommandTest): self.call(general.CmdPose(), "looks around", "Char looks around") def test_nick(self): - self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") - self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.") - self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.") + self.call(general.CmdNick(), "testalias = testaliasedstring1", + "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") + self.call(general.CmdNick(), "/account testalias = testaliasedstring2", + "Accountnick 'testalias' mapped to 'testaliasedstring2'.") + self.call(general.CmdNick(), "/object testalias = testaliasedstring3", + "Objectnick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) - self.assertEqual(None, self.char1.nicks.get("testalias", category="account")) - self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account")) + self.assertEqual(u"testaliasedstring2", self.char1.nicks.get("testalias", category="account")) + self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) self.assertEqual(u"testaliasedstring3", self.char1.nicks.get("testalias", category="object")) def test_get_and_drop(self): self.call(general.CmdGet(), "Obj", "You pick up Obj.") self.call(general.CmdDrop(), "Obj", "You drop Obj.") + def test_give(self): + self.call(general.CmdGive(), "Obj to Char2", "You aren't carrying Obj.") + self.call(general.CmdGive(), "Obj = Char2", "You aren't carrying Obj.") + self.call(general.CmdGet(), "Obj", "You pick up Obj.") + self.call(general.CmdGive(), "Obj to Char2", "You give") + self.call(general.CmdGive(), "Obj = Char", "You give", caller=self.char2) + + def test_mux_command(self): + + class CmdTest(MuxCommand): + key = 'test' + switch_options = ('test', 'testswitch', 'testswitch2') + + def func(self): + self.msg("Switches matched: {}".format(self.switches)) + + self.call(CmdTest(), "/test/testswitch/testswitch2", "Switches matched: ['test', 'testswitch', 'testswitch2']") + self.call(CmdTest(), "/test", "Switches matched: ['test']") + self.call(CmdTest(), "/test/testswitch", "Switches matched: ['test', 'testswitch']") + self.call(CmdTest(), "/testswitch/testswitch2", "Switches matched: ['testswitch', 'testswitch2']") + self.call(CmdTest(), "/testswitch", "Switches matched: ['testswitch']") + self.call(CmdTest(), "/testswitch2", "Switches matched: ['testswitch2']") + self.call(CmdTest(), "/t", "test: Ambiguous switch supplied: " + "Did you mean /test or /testswitch or /testswitch2?|Switches matched: []") + self.call(CmdTest(), "/tests", "test: Ambiguous switch supplied: " + "Did you mean /testswitch or /testswitch2?|Switches matched: []") + def test_say(self): self.call(general.CmdSay(), "Testing", "You say, \"Testing\"") @@ -225,7 +255,8 @@ class TestAccount(CommandTest): self.call(account.CmdColorTest(), "ansi", "ANSI colors:", caller=self.account) def test_char_create(self): - self.call(account.CmdCharCreate(), "Test1=Test char", "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account) + self.call(account.CmdCharCreate(), "Test1=Test char", + "Created new character Test1. Use @ic Test1 to enter the game", caller=self.account) def test_quell(self): self.call(account.CmdQuell(), "", "Quelling to current puppet's permissions (developer).", caller=self.account) @@ -234,16 +265,19 @@ class TestAccount(CommandTest): class TestBuilding(CommandTest): def test_create(self): name = settings.BASE_OBJECT_TYPECLASS.rsplit('.', 1)[1] - self.call(building.CmdCreate(), "/drop TestObj1", "You create a new %s: TestObj1." % name) + self.call(building.CmdCreate(), "/d TestObj1", # /d switch is abbreviated form of /drop + "You create a new %s: TestObj1." % name) def test_examine(self): self.call(building.CmdExamine(), "Obj", "Name/key: Obj") def test_set_obj_alias(self): - self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to testobj1b.") + self.call(building.CmdSetObjAlias(), "Obj =", "Cleared aliases from Obj(#4)") + self.call(building.CmdSetObjAlias(), "Obj = TestObj1b", "Alias(es) for 'Obj(#4)' set to 'testobj1b'.") def test_copy(self): - self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']") + self.call(building.CmdCopy(), "Obj = TestObj2;TestObj2b, TestObj3;TestObj3b", + "Copied Obj to 'TestObj3' (aliases: ['TestObj3b']") def test_attribute_commands(self): self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'") @@ -286,19 +320,35 @@ class TestBuilding(CommandTest): def test_typeclass(self): self.call(building.CmdTypeclass(), "Obj = evennia.objects.objects.DefaultExit", - "Obj changed typeclass from evennia.objects.objects.DefaultObject to evennia.objects.objects.DefaultExit.") + "Obj changed typeclass from evennia.objects.objects.DefaultObject " + "to evennia.objects.objects.DefaultExit.") def test_lock(self): self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") def test_find(self): self.call(building.CmdFind(), "Room2", "One Match") + expect = "One Match(#1#7, loc):\n " +\ + "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" + self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") + self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch + "locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect, + cmdstring="locate") + self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") + self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc + self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") def test_teleport(self): - self.call(building.CmdTeleport(), "Room2", "Room2(#2)\n|Teleported to Room2.") + self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.") + self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone + "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.") + self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc + "Destination has no location.") + self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet + "Char is already at Room2.") def test_spawn(self): def getObject(commandTest, objKeyStr): @@ -314,7 +364,7 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - self.call(building.CmdSpawn(), \ + self.call(building.CmdSpawn(), "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") goblin = getObject(self, "goblin") @@ -333,8 +383,8 @@ class TestBuilding(CommandTest): # char1's default location in the future... spawnLoc = self.room1 - self.call(building.CmdSpawn(), \ - "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" \ + self.call(building.CmdSpawn(), + "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") self.assertEqual(goblin.location, spawnLoc) @@ -347,70 +397,81 @@ class TestBuilding(CommandTest): self.assertIsInstance(ball, DefaultObject) ball.delete() - # Tests "@spawn/noloc ..." without specifying a location. + # Tests "@spawn/n ..." without specifying a location. # Location should be "None". - self.call(building.CmdSpawn(), "/noloc 'BALL'", "Spawned Ball") + self.call(building.CmdSpawn(), "/n 'BALL'", "Spawned Ball") # /n switch is abbreviated form of /noloc ball = getObject(self, "Ball") self.assertIsNone(ball.location) ball.delete() # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. - self.call(building.CmdSpawn(), \ - "/noloc {'prototype':'BALL', 'location':'%s'}" \ + self.call(building.CmdSpawn(), + "/noloc {'prototype':'BALL', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) ball.delete() # test calling spawn with an invalid prototype. - self.call(building.CmdSpawn(), \ - "'NO_EXIST'", "No prototype named 'NO_EXIST'") + self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") class TestComms(CommandTest): def setUp(self): super(CommandTest, self).setUp() - self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", "Created channel testchan and connected to it.", receiver=self.account) + self.call(comms.CmdChannelCreate(), "testchan;test=Test Channel", + "Created channel testchan and connected to it.", receiver=self.account) def test_toggle_com(self): - self.call(comms.CmdAddCom(), "tc = testchan", "You are already connected to channel testchan. You can now", receiver=self.account) + self.call(comms.CmdAddCom(), "tc = testchan", + "You are already connected to channel testchan. You can now", receiver=self.account) self.call(comms.CmdDelCom(), "tc", "Your alias 'tc' for channel testchan was cleared.", receiver=self.account) def test_channels(self): - self.call(comms.CmdChannels(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) + self.call(comms.CmdChannels(), "", + "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) def test_all_com(self): - self.call(comms.CmdAllCom(), "", "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) + self.call(comms.CmdAllCom(), "", + "Available channels (use comlist,addcom and delcom to manage", receiver=self.account) def test_clock(self): - self.call(comms.CmdClock(), "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account) + self.call(comms.CmdClock(), + "testchan=send:all()", "Lock(s) applied. Current locks on testchan:", receiver=self.account) def test_cdesc(self): - self.call(comms.CmdCdesc(), "testchan = Test Channel", "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account) + self.call(comms.CmdCdesc(), "testchan = Test Channel", + "Description of channel 'testchan' set to 'Test Channel'.", receiver=self.account) def test_cemit(self): - self.call(comms.CmdCemit(), "testchan = Test Message", "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account) + self.call(comms.CmdCemit(), "testchan = Test Message", + "[testchan] Test Message|Sent to channel testchan: Test Message", receiver=self.account) def test_cwho(self): self.call(comms.CmdCWho(), "testchan", "Channel subscriptions\ntestchan:\n TestAccount", receiver=self.account) def test_page(self): - self.call(comms.CmdPage(), "TestAccount2 = Test", "TestAccount2 is offline. They will see your message if they list their pages later.|You paged TestAccount2 with: 'Test'.", receiver=self.account) + self.call(comms.CmdPage(), "TestAccount2 = Test", + "TestAccount2 is offline. They will see your message if they list their pages later." + "|You paged TestAccount2 with: 'Test'.", receiver=self.account) def test_cboot(self): # No one else connected to boot self.call(comms.CmdCBoot(), "", "Usage: @cboot[/quiet] = [:reason]", receiver=self.account) def test_cdestroy(self): - self.call(comms.CmdCdestroy(), "testchan", "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases.|Channel 'testchan' was destroyed.", receiver=self.account) + self.call(comms.CmdCdestroy(), "testchan", + "[testchan] TestAccount: testchan is being destroyed. Make sure to change your aliases." + "|Channel 'testchan' was destroyed.", receiver=self.account) class TestBatchProcess(CommandTest): def test_batch_commands(self): # cannot test batchcode here, it must run inside the server process - self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batchcommand processor Automatic mode for example_batch_cmds") + self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", + "Running Batchcommand processor Automatic mode for example_batch_cmds") # we make sure to delete the button again here to stop the running reactor confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 429948b0fb..67e15dfd38 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -293,7 +293,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): session.msg(string) return if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)): - string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \ + string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \ "\nFor best security, make it longer than 8 characters. You can also use a phrase of" \ "\nmany words if you enclose the password in double quotes." session.msg(string) @@ -557,7 +557,7 @@ def _create_character(session, new_account, typeclass, home, permissions): # If no description is set, set a default description if not new_character.db.desc: - new_character.db.desc = "This is an Account." + new_character.db.desc = "This is a character." # We need to set this to have @ic auto-connect to this character new_account.db._last_puppet = new_character except Exception as e: diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index e40de664d1..7ff62dfc27 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -26,6 +26,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): the hooks called by this method. """ + self.basetype_setup() self.at_channel_creation() self.attributes.add("log_file", "channel_%s.log" % self.key) if hasattr(self, "_createdict"): @@ -46,11 +47,7 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): if cdict.get("desc"): self.attributes.add("desc", cdict["desc"]) - def at_channel_creation(self): - """ - Called once, when the channel is first created. - - """ + def basetype_setup(self): # delayed import of the channelhandler global _CHANNEL_HANDLER if not _CHANNEL_HANDLER: @@ -58,6 +55,15 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): # register ourselves with the channelhandler. _CHANNEL_HANDLER.add(self) + self.locks.add("send:all();listen:all();control:perm(Admin)") + + def at_channel_creation(self): + """ + Called once, when the channel is first created. + + """ + pass + # helper methods, for easy overloading def has_connection(self, subscriber): diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index ed3c048c32..5ca11b1799 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -16,10 +16,10 @@ things you want from here into your game folder and change them there. ## Contrib modules * Barter system (Griatch 2012) - A safe and effective barter-system - for any game. Allows safe trading of any godds (including coin) + for any game. Allows safe trading of any goods (including coin). * CharGen (Griatch 2011) - A simple Character creator for OOC mode. Meant as a starting point for a more fleshed-out system. -* Clothing (BattleJenkins 2017) - A layered clothing system with +* Clothing (FlutterSprite 2017) - A layered clothing system with slots for different types of garments auto-showing in description. * Color-markups (Griatch, 2017) - Alternative in-game color markups. * Custom gametime (Griatch, vlgeoff 2017) - Implements Evennia's @@ -33,7 +33,7 @@ things you want from here into your game folder and change them there. on a character and access it in an emote with a custom marker. * Mail (grungies1138 2016) - An in-game mail system for communication. * Menu login (Griatch 2011) - A login system using menus asking - for name/password rather than giving them as one command + for name/password rather than giving them as one command. * Map Builder (CloudKeeper 2016) - Build a game area based on a 2D "graphical" unicode map. Supports assymmetric exits. * Menu Login (Vincent-lg 2016) - Alternate login system using EvMenu. @@ -50,8 +50,9 @@ things you want from here into your game folder and change them there. time to pass depending on if you are walking/running etc. * Talking NPC (Griatch 2011) - A talking NPC object that offers a menu-driven conversation tree. -* Turnbattle (BattleJenkins 2017) - A turn-based combat engine meant - as a start to build from. Has attack/disengage and turn timeouts. +* Tree Select (FlutterSprite 2017) - A simple system for creating a + branching EvMenu with selection options sourced from a single + multi-line string. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. @@ -59,9 +60,12 @@ things you want from here into your game folder and change them there. ## Contrib packages * EGI_Client (gtaylor 2016) - Client for reporting game status - to the Evennia game index (games.evennia.com) + to the Evennia game index (games.evennia.com). * In-game Python (Vincent Le Goff 2017) - Allow trusted builders to script objects and events using Python from in-game. +* Turnbattle (FlutterSprite 2017) - A turn-based combat engine meant + as a start to build from. Has attack/disengage and turn timeouts, + and includes optional expansions for equipment and combat movement. * Tutorial examples (Griatch 2011, 2015) - A folder of basic example objects, commands and scripts. * Tutorial world (Griatch 2011, 2015) - A folder containing the diff --git a/evennia/contrib/email_login.py b/evennia/contrib/email_login.py index 01d08abab4..c98bf4aa88 100644 --- a/evennia/contrib/email_login.py +++ b/evennia/contrib/email_login.py @@ -197,7 +197,7 @@ class CmdUnconnectedCreate(MuxCommand): session.msg(string) return if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)): - string = "\n\r Password should be longer than 3 characers. Letters, spaces, digits and @/./+/-/_/' only." \ + string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \ "\nFor best security, make it longer than 8 characters. You can also use a phrase of" \ "\nmany words if you enclose the password in double quotes." session.msg(string) diff --git a/evennia/contrib/extended_room.py b/evennia/contrib/extended_room.py index 0530bd796c..6cacca980c 100644 --- a/evennia/contrib/extended_room.py +++ b/evennia/contrib/extended_room.py @@ -213,37 +213,39 @@ class ExtendedRoom(DefaultRoom): return detail return None - def return_appearance(self, looker): + def return_appearance(self, looker, **kwargs): """ This is called when e.g. the look command wants to retrieve the description of this object. Args: looker (Object): The object looking at us. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). Returns: description (str): Our description. """ - update = False + # ensures that our description is current based on time/season + self.update_current_description() + # run the normal return_appearance method, now that desc is updated. + return super(ExtendedRoom, self).return_appearance(looker, **kwargs) + def update_current_description(self): + """ + This will update the description of the room if the time or season + has changed since last checked. + """ + update = False # get current time and season curr_season, curr_timeslot = self.get_time_and_season() - # compare with previously stored slots last_season = self.ndb.last_season last_timeslot = self.ndb.last_timeslot - if curr_season != last_season: # season changed. Load new desc, or a fallback. - if curr_season == 'spring': - new_raw_desc = self.db.spring_desc - elif curr_season == 'summer': - new_raw_desc = self.db.summer_desc - elif curr_season == 'autumn': - new_raw_desc = self.db.autumn_desc - else: - new_raw_desc = self.db.winter_desc + new_raw_desc = self.attributes.get("%s_desc" % curr_season) if new_raw_desc: raw_desc = new_raw_desc else: @@ -252,19 +254,15 @@ class ExtendedRoom(DefaultRoom): self.db.raw_desc = raw_desc self.ndb.last_season = curr_season update = True - if curr_timeslot != last_timeslot: # timeslot changed. Set update flag. self.ndb.last_timeslot = curr_timeslot update = True - if update: # if anything changed we have to re-parse # the raw_desc for time markers # and re-save the description again. self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot) - # run the normal return_appearance method, now that desc is updated. - return super(ExtendedRoom, self).return_appearance(looker) # Custom Look command supporting Room details. Add this to @@ -369,6 +367,7 @@ class CmdExtendedDesc(default_cmds.CmdDesc): """ aliases = ["describe", "detail"] + switch_options = () # Inherits from default_cmds.CmdDesc, but unused here def reset_times(self, obj): """By deleteting the caches we force a re-load.""" diff --git a/evennia/contrib/health_bar.py b/evennia/contrib/health_bar.py new file mode 100644 index 0000000000..c3d4af1c52 --- /dev/null +++ b/evennia/contrib/health_bar.py @@ -0,0 +1,103 @@ +""" +Health Bar + +Contrib - Tim Ashley Jenkins 2017 + +The function provided in this module lets you easily display visual +bars or meters - "health bar" is merely the most obvious use for this, +though these bars are highly customizable and can be used for any sort +of appropriate data besides player health. + +Today's players may be more used to seeing statistics like health, +stamina, magic, and etc. displayed as bars rather than bare numerical +values, so using this module to present this data this way may make it +more accessible. Keep in mind, however, that players may also be using +a screen reader to connect to your game, which will not be able to +represent the colors of the bar in any way. By default, the values +represented are rendered as text inside the bar which can be read by +screen readers. + +The health bar will account for current values above the maximum or +below 0, rendering them as a completely full or empty bar with the +values displayed within. +""" + +def display_meter(cur_value, max_value, + length=30, fill_color=["R", "Y", "G"], + empty_color="B", text_color="w", + align="left", pre_text="", post_text="", + show_values=True): + """ + Represents a current and maximum value given as a "bar" rendered with + ANSI or xterm256 background colors. + + Args: + cur_value (int): Current value to display + max_value (int): Maximum value to display + + Options: + length (int): Length of meter returned, in characters + fill_color (list): List of color codes for the full portion + of the bar, sans any sort of prefix - both ANSI and xterm256 + colors are usable. When the bar is empty, colors toward the + start of the list will be chosen - when the bar is full, colors + towards the end are picked. You can adjust the 'weights' of + the changing colors by adding multiple entries of the same + color - for example, if you only want the bar to change when + it's close to empty, you could supply ['R','Y','G','G','G'] + empty_color (str): Color code for the empty portion of the bar. + text_color (str): Color code for text inside the bar. + align (str): "left", "right", or "center" - alignment of text in the bar + pre_text (str): Text to put before the numbers in the bar + post_text (str): Text to put after the numbers in the bar + show_values (bool): If true, shows the numerical values represented by + the bar. It's highly recommended you keep this on, especially if + there's no info given in pre_text or post_text, as players on screen + readers will be unable to read the graphical aspect of the bar. + """ + # Start by building the base string. + num_text = "" + if show_values: + num_text = "%i / %i" % (cur_value, max_value) + bar_base_str = pre_text + num_text + post_text + # Cut down the length of the base string if needed + if len(bar_base_str) > length: + bar_base_str = bar_base_str[:length] + # Pad and align the bar base string + if align == "right": + bar_base_str = bar_base_str.rjust(length, " ") + elif align == "center": + bar_base_str = bar_base_str.center(length, " ") + else: + bar_base_str = bar_base_str.ljust(length, " ") + + if max_value < 1: # Prevent divide by zero + max_value = 1 + if cur_value < 0: # Prevent weirdly formatted 'negative bars' + cur_value = 0 + if cur_value > max_value: # Display overfull bars correctly + cur_value = max_value + + # Now it's time to determine where to put the color codes. + percent_full = float(cur_value) / float(max_value) + split_index = round(float(length) * percent_full) + # Determine point at which to split the bar + split_index = int(split_index) + + # Separate the bar string into full and empty portions + full_portion = bar_base_str[:split_index] + empty_portion = bar_base_str[split_index:] + + # Pick which fill color to use based on how full the bar is + fillcolor_index = (float(len(fill_color)) * percent_full) + fillcolor_index = int(round(fillcolor_index)) - 1 + fillcolor_code = "|[" + fill_color[fillcolor_index] + + # Make color codes for empty bar portion and text_color + emptycolor_code = "|[" + empty_color + textcolor_code = "|" + text_color + + # Assemble the final bar + final_bar = fillcolor_code + textcolor_code + full_portion + "|n" + emptycolor_code + textcolor_code + empty_portion + "|n" + + return final_bar diff --git a/evennia/contrib/ingame_python/typeclasses.py b/evennia/contrib/ingame_python/typeclasses.py index 33729bef66..3f52b982bf 100644 --- a/evennia/contrib/ingame_python/typeclasses.py +++ b/evennia/contrib/ingame_python/typeclasses.py @@ -430,7 +430,7 @@ class EventCharacter(DefaultCharacter): # Browse all the room's other characters for obj in location.contents: - if obj is self or not inherits_from(obj, "objects.objects.DefaultCharacter"): + if obj is self or not inherits_from(obj, "evennia.objects.objects.DefaultCharacter"): continue allow = obj.callbacks.call("can_say", self, obj, message, parameters=message) @@ -491,7 +491,7 @@ class EventCharacter(DefaultCharacter): parameters=message) # Call the other characters' "say" event - presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "objects.objects.DefaultCharacter")] + presents = [obj for obj in location.contents if obj is not self and inherits_from(obj, "evennia.objects.objects.DefaultCharacter")] for present in presents: present.callbacks.call("say", self, present, message, parameters=message) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ff6b01d5cf..30bf71dcc8 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,6 +670,15 @@ class TestGenderSub(CommandTest): char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) txt = "Test |p gender" self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") + +# test health bar contrib + +from evennia.contrib import health_bar + +class TestHealthBar(EvenniaTest): + def test_healthbar(self): + expected_bar_str = "|[G|w |n|[B|w test24 / 200test |n" + self.assertTrue(health_bar.display_meter(24, 200, length=40, pre_text="test", post_text="test", align="center") == expected_bar_str) # test mail contrib @@ -944,7 +953,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib import turnbattle +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range from evennia.objects.objects import DefaultRoom @@ -952,60 +961,94 @@ class TestTurnBattleCmd(CommandTest): # Test combat commands def test_turnbattlecmd(self): - self.call(turnbattle.CmdFight(), "", "You can't start a fight if you've been defeated!") - self.call(turnbattle.CmdAttack(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdPass(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") - self.call(turnbattle.CmdRest(), "", "Char rests to recover HP.") - + self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") + + # Test equipment commands + def test_turnbattleequipcmd(self): + # Start with equip module specific commands. + testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + testarmor = create_object(tb_equip.TBEArmor, key="test armor") + testweapon.move_to(self.char1) + testarmor.move_to(self.char1) + self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.") + self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.") + self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.") + self.call(tb_equip.CmdDoff(), "", "Char removes test armor.") + # Also test the commands that are the same in the basic module + self.call(tb_equip.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_equip.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") + + # Test range commands + def test_turnbattlerangecmd(self): + # Start with range module specific commands. + self.call(tb_range.CmdShoot(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdApproach(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdWithdraw(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdStatus(), "", "HP Remaining: 100 / 100") + # Also test the commands that are the same in the basic module + self.call(tb_range.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_range.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") + class TestTurnBattleFunc(EvenniaTest): # Test combat functions - def test_turnbattlefunc(self): - attacker = create_object(turnbattle.BattleCharacter, key="Attacker") - defender = create_object(turnbattle.BattleCharacter, key="Defender") + def test_tbbasicfunc(self): + attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") + defender = create_object(tb_basic.TBBasicCharacter, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom # Initiative roll - initiative = turnbattle.roll_init(attacker) + initiative = tb_basic.roll_init(attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = turnbattle.get_attack(attacker, defender) + attack_roll = tb_basic.get_attack(attacker, defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = turnbattle.get_defense(attacker, defender) + defense_roll = tb_basic.get_defense(attacker, defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = turnbattle.get_damage(attacker, defender) + damage_roll = tb_basic.get_damage(attacker, defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage defender.db.hp = 10 - turnbattle.apply_damage(defender, 3) + tb_basic.apply_damage(defender, 3) self.assertTrue(defender.db.hp == 7) # Resolve attack defender.db.hp = 40 - turnbattle.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) self.assertTrue(defender.db.hp < 40) # Combat cleanup attacker.db.Combat_attribute = True - turnbattle.combat_cleanup(attacker) + tb_basic.combat_cleanup(attacker) self.assertFalse(attacker.db.combat_attribute) # Is in combat - self.assertFalse(turnbattle.is_in_combat(attacker)) + self.assertFalse(tb_basic.is_in_combat(attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(turnbattle.TurnHandler) + attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) turnhandler = attacker.db.combat_TurnHandler self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 # Force turn order turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 # Test is turn - self.assertTrue(turnbattle.is_turn(attacker)) + self.assertTrue(tb_basic.is_turn(attacker)) # Spend actions attacker.db.Combat_ActionsLeft = 1 - turnbattle.spend_action(attacker, 1, action_name="Test") + tb_basic.spend_action(attacker, 1, action_name="Test") self.assertTrue(attacker.db.Combat_ActionsLeft == 0) self.assertTrue(attacker.db.Combat_LastAction == "Test") # Initialize for combat @@ -1029,7 +1072,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(turnbattle.BattleCharacter, key="Joiner") + joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1037,7 +1080,208 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() + + # Test the combat functions in tb_equip too. They work mostly the same. + def test_tbequipfunc(self): + attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") + defender = create_object(tb_equip.TBEquipCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_equip.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_equip.get_attack(attacker, defender) + self.assertTrue(attack_roll >= -50 and attack_roll <= 150) + # Defense roll + defense_roll = tb_equip.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_equip.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 0 and damage_roll <= 50) + # Apply damage + defender.db.hp = 10 + tb_equip.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_equip.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_equip.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_equip.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_equip.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 1) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Remove the script at the end + turnhandler.stop() + + # Test combat functions in tb_range too. + def test_tbrangefunc(self): + testroom = create_object(DefaultRoom, key="Test Room") + attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom) + defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom) + # Initiative roll + initiative = tb_range.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_range.get_attack(attacker, defender, "test") + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_range.get_defense(attacker, defender, "test") + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_range.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_range.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_range.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_range.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_range.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_range.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Set up ranges again, since initialize_for_combat clears them + attacker.db.combat_range = {} + attacker.db.combat_range[attacker] = 0 + attacker.db.combat_range[defender] = 1 + defender.db.combat_range = {} + defender.db.combat_range[defender] = 0 + defender.db.combat_range[attacker] = 1 + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 2) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=testroom) + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Now, test for approach/withdraw functions + self.assertTrue(tb_range.get_range(attacker, defender) == 1) + # Approach + tb_range.approach(attacker, defender) + self.assertTrue(tb_range.get_range(attacker, defender) == 0) + # Withdraw + tb_range.withdraw(attacker, defender) + self.assertTrue(tb_range.get_range(attacker, defender) == 1) + # Remove the script at the end + turnhandler.stop() +# Test tree select + +from evennia.contrib import tree_select + +TREE_MENU_TESTSTR = """Foo +Bar +-Baz +--Baz 1 +--Baz 2 +-Qux""" + +class TestTreeSelectFunc(EvenniaTest): + + def test_tree_functions(self): + # Dash counter + self.assertTrue(tree_select.dashcount("--test") == 2) + # Is category + self.assertTrue(tree_select.is_category(TREE_MENU_TESTSTR, 1) == True) + # Parse options + self.assertTrue(tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) == [(3, "Baz 1"), (4, "Baz 2")]) + # Index to selection + self.assertTrue(tree_select.index_to_selection(TREE_MENU_TESTSTR, 2) == "Baz") + # Go up one category + self.assertTrue(tree_select.go_up_one_category(TREE_MENU_TESTSTR, 4) == 2) + # Option list to menu options + test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) + optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] + self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) # Test of the unixcommand module diff --git a/evennia/contrib/tree_select.py b/evennia/contrib/tree_select.py new file mode 100644 index 0000000000..d2854fc83f --- /dev/null +++ b/evennia/contrib/tree_select.py @@ -0,0 +1,535 @@ +""" +Easy menu selection tree + +Contrib - Tim Ashley Jenkins 2017 + +This module allows you to create and initialize an entire branching EvMenu +instance with nothing but a multi-line string passed to one function. + +EvMenu is incredibly powerful and flexible, but using it for simple menus +can often be fairly cumbersome - a simple menu that can branch into five +categories would require six nodes, each with options represented as a list +of dictionaries. + +This module provides a function, init_tree_selection, which acts as a frontend +for EvMenu, dynamically sourcing the options from a multi-line string you provide. +For example, if you define a string as such: + + TEST_MENU = '''Foo + Bar + Baz + Qux''' + +And then use TEST_MENU as the 'treestr' source when you call init_tree_selection +on a player: + + init_tree_selection(TEST_MENU, caller, callback) + +The player will be presented with an EvMenu, like so: + + ___________________________ + + Make your selection: + ___________________________ + + Foo + Bar + Baz + Qux + +Making a selection will pass the selection's key to the specified callback as a +string along with the caller, as well as the index of the selection (the line number +on the source string) along with the source string for the tree itself. + +In addition to specifying selections on the menu, you can also specify categories. +Categories are indicated by putting options below it preceded with a '-' character. +If a selection is a category, then choosing it will bring up a new menu node, prompting +the player to select between those options, or to go back to the previous menu. In +addition, categories are marked by default with a '[+]' at the end of their key. Both +this marker and the option to go back can be disabled. + +Categories can be nested in other categories as well - just go another '-' deeper. You +can do this as many times as you like. There's no hard limit to the number of +categories you can go down. + +For example, let's add some more options to our menu, turning 'Bar' into a category. + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz + Qux''' + +Now when we call the menu, we can see that 'Bar' has become a category instead of a +selectable option. + + _______________________________ + + Make your selection: + _______________________________ + + Foo + Bar [+] + Baz + Qux + +Note the [+] next to 'Bar'. If we select 'Bar', it'll show us the option listed under it. + + ________________________________________________________________ + + Bar + ________________________________________________________________ + + You've got to know [+] + << Go Back: Return to the previous menu. + +Just the one option, which is a category itself, and the option to go back, which will +take us back to the previous menu. Let's select 'You've got to know'. + + ________________________________________________________________ + + You've got to know + ________________________________________________________________ + + When to hold em + When to fold em + When to walk away + << Go Back: Return to the previous menu. + +Now we see the three options listed under it, too. We can select one of them or use 'Go +Back' to return to the 'Bar' menu we were just at before. It's very simple to make a +branching tree of selections! + +One last thing - you can set the descriptions for the various options simply by adding a +':' character followed by the description to the option's line. For example, let's add a +description to 'Baz' in our menu: + + TEST_MENU = '''Foo + Bar + -You've got to know + --When to hold em + --When to fold em + --When to walk away + Baz: Look at this one: the best option. + Qux''' + +Now we see that the Baz option has a description attached that's separate from its key: + + _______________________________________________________________ + + Make your selection: + _______________________________________________________________ + + Foo + Bar [+] + Baz: Look at this one: the best option. + Qux + +Once the player makes a selection - let's say, 'Foo' - the menu will terminate and call +your specified callback with the selection, like so: + + callback(caller, TEST_MENU, 0, "Foo") + +The index of the selection is given along with a string containing the selection's key. +That way, if you have two selections in the menu with the same key, you can still +differentiate between them. + +And that's all there is to it! For simple branching-tree selections, using this system is +much easier than manually creating EvMenu nodes. It also makes generating menus with dynamic +options much easier - since the source of the menu tree is just a string, you could easily +generate that string procedurally before passing it to the init_tree_selection function. +For example, if a player casts a spell or does an attack without specifying a target, instead +of giving them an error, you could present them with a list of valid targets to select by +generating a multi-line string of targets and passing it to init_tree_selection, with the +callable performing the maneuver once a selection is made. + +This selection system only works for simple branching trees - doing anything really complicated +like jumping between categories or prompting for arbitrary input would still require a full +EvMenu implementation. For simple selections, however, I'm sure you will find using this function +to be much easier! + +Included in this module is a sample menu and function which will let a player change the color +of their name - feel free to mess with it to get a feel for how this system works by importing +this module in your game's default_cmdsets.py module and adding CmdNameColor to your default +character's command set. +""" + +from evennia.utils import evmenu +from evennia.utils.logger import log_trace +from evennia import Command + +def init_tree_selection(treestr, caller, callback, + index=None, mark_category=True, go_back=True, + cmd_on_exit="look", + start_text="Make your selection:"): + """ + Prompts a player to select an option from a menu tree given as a multi-line string. + + Args: + treestr (str): Multi-lne string representing menu options + caller (obj): Player to initialize the menu for + callback (callable): Function to run when a selection is made. Must take 4 args: + caller (obj): Caller given above + treestr (str): Menu tree string given above + index (int): Index of final selection + selection (str): Key of final selection + + Options: + index (int or None): Index to start the menu at, or None for top level + mark_category (bool): If True, marks categories with a [+] symbol in the menu + go_back (bool): If True, present an option to go back to previous categories + start_text (str): Text to display at the top level of the menu + cmd_on_exit(str): Command to enter when the menu exits - 'look' by default + + + Notes: + This function will initialize an instance of EvMenu with options generated + dynamically from the source string, and passes the menu user's selection to + a function of your choosing. The EvMenu is made of a single, repeating node, + which will call itself over and over at different levels of the menu tree as + categories are selected. + + Once a non-category selection is made, the user's selection will be passed to + the given callable, both as a string and as an index number. The index is given + to ensure every selection has a unique identifier, so that selections with the + same key in different categories can be distinguished between. + + The menus called by this function are not persistent and cannot perform + complicated tasks like prompt for arbitrary input or jump multiple category + levels at once - you'll have to use EvMenu itself if you want to take full + advantage of its features. + """ + + # Pass kwargs to store data needed in the menu + kwargs = { + "index":index, + "mark_category":mark_category, + "go_back":go_back, + "treestr":treestr, + "callback":callback, + "start_text":start_text + } + + # Initialize menu of selections + evmenu.EvMenu(caller, "evennia.contrib.tree_select", startnode="menunode_treeselect", + startnode_input=None, cmd_on_exit=cmd_on_exit, **kwargs) + +def dashcount(entry): + """ + Counts the number of dashes at the beginning of a string. This + is needed to determine the depth of options in categories. + + Args: + entry (str): String to count the dashes at the start of + + Returns: + dashes (int): Number of dashes at the start + """ + dashes = 0 + for char in entry: + if char == "-": + dashes += 1 + else: + return dashes + return dashes + +def is_category(treestr, index): + """ + Determines whether an option in a tree string is a category by + whether or not there are additional options below it. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Which line of the string to test + + Returns: + is_category (bool): Whether the option is a category + """ + opt_list = treestr.split('\n') + # Not a category if it's the last one in the list + if index == len(opt_list) - 1: + return False + # Not a category if next option is not one level deeper + return not bool(dashcount(opt_list[index+1]) != dashcount(opt_list[index]) + 1) + +def parse_opts(treestr, category_index=None): + """ + Parses a tree string and given index into a list of options. If + category_index is none, returns all the options at the top level of + the menu. If category_index corresponds to a category, returns a list + of options under that category. If category_index corresponds to + an option that is not a category, it's a selection and returns True. + + Args: + treestr (str): Multi-line string representing menu options + category_index (int): Index of category or None for top level + + Returns: + kept_opts (list or True): Either a list of options in the selected + category or True if a selection was made + """ + dash_depth = 0 + opt_list = treestr.split('\n') + kept_opts = [] + + # If a category index is given + if category_index != None: + # If given index is not a category, it's a selection - return True. + if not is_category(treestr, category_index): + return True + # Otherwise, change the dash depth to match the new category. + dash_depth = dashcount(opt_list[category_index]) + 1 + # Delete everything before the category index + opt_list = opt_list [category_index+1:] + + # Keep every option (referenced by index) at the appropriate depth + cur_index = 0 + for option in opt_list: + if dashcount(option) == dash_depth: + if category_index == None: + kept_opts.append((cur_index, option[dash_depth:])) + else: + kept_opts.append((cur_index + category_index + 1, option[dash_depth:])) + # Exits the loop if leaving a category + if dashcount(option) < dash_depth: + return kept_opts + cur_index += 1 + return kept_opts + +def index_to_selection(treestr, index, desc=False): + """ + Given a menu tree string and an index, returns the corresponding selection's + name as a string. If 'desc' is set to True, will return the selection's + description as a string instead. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to convert to selection key or description + + Options: + desc (bool): If true, returns description instead of key + + Returns: + selection (str): Selection key or description if 'desc' is set + """ + opt_list = treestr.split('\n') + # Fetch the given line + selection = opt_list[index] + # Strip out the dashes at the start + selection = selection[dashcount(selection):] + # Separate out description, if any + if ":" in selection: + # Split string into key and description + selection = selection.split(':', 1) + selection[1] = selection[1].strip(" ") + else: + # If no description given, set description to None + selection = [selection, None] + if not desc: + return selection[0] + else: + return selection[1] + +def go_up_one_category(treestr, index): + """ + Given a menu tree string and an index, returns the category that the given option + belongs to. Used for the 'go back' option. + + Args: + treestr (str): Multi-line string representing menu options + index (int): Index to determine the parent category of + + Returns: + parent_category (int): Index of parent category + """ + opt_list = treestr.split('\n') + # Get the number of dashes deep the given index is + dash_level = dashcount(opt_list[index]) + # Delete everything after the current index + opt_list = opt_list[:index+1] + + + # If there's no dash, return 'None' to return to base menu + if dash_level == 0: + return None + current_index = index + # Go up through each option until we find one that's one category above + for selection in reversed(opt_list): + if dashcount(selection) == dash_level - 1: + return current_index + current_index -= 1 + +def optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back): + """ + Takes a list of options processed by parse_opts and turns it into + a list/dictionary of menu options for use in menunode_treeselect. + + Args: + treestr (str): Multi-line string representing menu options + optlist (list): List of options to convert to EvMenu's option format + index (int): Index of current category + mark_category (bool): Whether or not to mark categories with [+] + go_back (bool): Whether or not to add an option to go back in the menu + + Returns: + menuoptions (list of dicts): List of menu options formatted for use + in EvMenu, each passing a different "newindex" kwarg that changes + the menu level or makes a selection + """ + + menuoptions = [] + cur_index = 0 + for option in optlist: + index_to_add = optlist[cur_index][0] + menuitem = {} + keystr = index_to_selection(treestr, index_to_add) + if mark_category and is_category(treestr, index_to_add): + # Add the [+] to the key if marking categories, and the key by itself as an alias + menuitem["key"] = [keystr + " [+]", keystr] + else: + menuitem["key"] = keystr + # Get the option's description + desc = index_to_selection(treestr, index_to_add, desc=True) + if desc: + menuitem["desc"] = desc + # Passing 'newindex' as a kwarg to the node is how we move through the menu! + menuitem["goto"] = ["menunode_treeselect", {"newindex":index_to_add}] + menuoptions.append(menuitem) + cur_index += 1 + # Add option to go back, if needed + if index != None and go_back == True: + gobackitem = {"key":["<< Go Back", "go back", "back"], + "desc":"Return to the previous menu.", + "goto":["menunode_treeselect", {"newindex":go_up_one_category(treestr, index)}]} + menuoptions.append(gobackitem) + return menuoptions + +def menunode_treeselect(caller, raw_string, **kwargs): + """ + This is the repeating menu node that handles the tree selection. + """ + + # If 'newindex' is in the kwargs, change the stored index. + if "newindex" in kwargs: + caller.ndb._menutree.index = kwargs["newindex"] + + # Retrieve menu info + index = caller.ndb._menutree.index + mark_category = caller.ndb._menutree.mark_category + go_back = caller.ndb._menutree.go_back + treestr = caller.ndb._menutree.treestr + callback = caller.ndb._menutree.callback + start_text = caller.ndb._menutree.start_text + + # List of options if index is 'None' or category, or 'True' if a selection + optlist = parse_opts(treestr, category_index=index) + + # If given index returns optlist as 'True', it's a selection. Pass to callback and end the menu. + if optlist == True: + selection = index_to_selection(treestr, index) + try: + callback(caller, treestr, index, selection) + except Exception: + log_trace("Error in tree selection callback.") + + # Returning None, None ends the menu. + return None, None + + # Otherwise, convert optlist to a list of menu options. + else: + options = optlist_to_menuoptions(treestr, optlist, index, mark_category, go_back) + if index == None: + # Use start_text for the menu text on the top level + text = start_text + else: + # Use the category name and description (if any) as the menu text + if index_to_selection(treestr, index, desc=True) != None: + text = "|w" + index_to_selection(treestr, index) + "|n: " + index_to_selection(treestr, index, desc=True) + else: + text = "|w" + index_to_selection(treestr, index) + "|n" + return text, options + +# The rest of this module is for the example menu and command! It'll change the color of your name. + +""" +Here's an example string that you can initialize a menu from. Note the dashes at +the beginning of each line - that's how menu option depth and hierarchy is determined. +""" + +NAMECOLOR_MENU = """Set name color: Choose a color for your name! +-Red shades: Various shades of |511red|n +--Red: |511Set your name to Red|n +--Pink: |533Set your name to Pink|n +--Maroon: |301Set your name to Maroon|n +-Orange shades: Various shades of |531orange|n +--Orange: |531Set your name to Orange|n +--Brown: |321Set your name to Brown|n +--Sienna: |420Set your name to Sienna|n +-Yellow shades: Various shades of |551yellow|n +--Yellow: |551Set your name to Yellow|n +--Gold: |540Set your name to Gold|n +--Dandelion: |553Set your name to Dandelion|n +-Green shades: Various shades of |141green|n +--Green: |141Set your name to Green|n +--Lime: |350Set your name to Lime|n +--Forest: |032Set your name to Forest|n +-Blue shades: Various shades of |115blue|n +--Blue: |115Set your name to Blue|n +--Cyan: |155Set your name to Cyan|n +--Navy: |113Set your name to Navy|n +-Purple shades: Various shades of |415purple|n +--Purple: |415Set your name to Purple|n +--Lavender: |535Set your name to Lavender|n +--Fuchsia: |503Set your name to Fuchsia|n +Remove name color: Remove your name color, if any""" + +class CmdNameColor(Command): + """ + Set or remove a special color on your name. Just an example for the + easy menu selection tree contrib. + """ + + key = "namecolor" + + def func(self): + # This is all you have to do to initialize a menu! + init_tree_selection(NAMECOLOR_MENU, self.caller, + change_name_color, + start_text="Name color options:") + +def change_name_color(caller, treestr, index, selection): + """ + Changes a player's name color. + + Args: + caller (obj): Character whose name to color. + treestr (str): String for the color change menu - unused + index (int): Index of menu selection - unused + selection (str): Selection made from the name color menu - used + to determine the color the player chose. + """ + + # Store the caller's uncolored name + if not caller.db.uncolored_name: + caller.db.uncolored_name = caller.key + + # Dictionary matching color selection names to color codes + colordict = { "Red":"|511", "Pink":"|533", "Maroon":"|301", + "Orange":"|531", "Brown":"|321", "Sienna":"|420", + "Yellow":"|551", "Gold":"|540", "Dandelion":"|553", + "Green":"|141", "Lime":"|350", "Forest":"|032", + "Blue":"|115", "Cyan":"|155", "Navy":"|113", + "Purple":"|415", "Lavender":"|535", "Fuchsia":"|503"} + + # I know this probably isn't the best way to do this. It's just an example! + if selection == "Remove name color": # Player chose to remove their name color + caller.key = caller.db.uncolored_name + caller.msg("Name color removed.") + elif selection in colordict: + newcolor = colordict[selection] # Retrieve color code based on menu selection + caller.key = newcolor + caller.db.uncolored_name + "|n" # Add color code to caller's name + caller.msg(newcolor + ("Name color changed to %s!" % selection) + "|n") + diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md new file mode 100644 index 0000000000..729c42a099 --- /dev/null +++ b/evennia/contrib/turnbattle/README.md @@ -0,0 +1,42 @@ +# Turn based battle system framework + +Contrib - Tim Ashley Jenkins 2017 + +This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends. + +This folder contains multiple examples of how such a system can be +implemented and customized: + + tb_basic.py - The simplest system, which implements initiative and turn + order, attack rolls against defense values, and damage to hit + points. Only very basic game mechanics are included. + + tb_equip.py - Adds weapons and armor to the basic implementation of + the battle system, including commands for wielding weapons and + donning armor, and modifiers to accuracy and damage based on + currently used equipment. + + tb_range.py - Adds a system for abstract positioning and movement, which + tracks the distance between different characters and objects in + combat, as well as differentiates between melee and ranged + attacks. + +This system is meant as a basic framework to start from, and is modeled +after the combat systems of popular tabletop role playing games rather than +the real-time battle systems that many MMOs and some MUDs use. As such, it +may be better suited to role-playing or more story-oriented games, or games +meant to closely emulate the experience of playing a tabletop RPG. + +Each of these modules contains the full functionality of the battle system +with different customizations added in - the instructions to install each +one is contained in the module itself. It's recommended that you install +and test tb_basic first, so you can better understand how the other +modules expand on it and get a better idea of how you can customize the +system to your liking and integrate the subsystems presented here into +your own combat system. diff --git a/evennia/contrib/turnbattle/__init__.py b/evennia/contrib/turnbattle/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/evennia/contrib/turnbattle/__init__.py @@ -0,0 +1 @@ + diff --git a/evennia/contrib/turnbattle.py b/evennia/contrib/turnbattle/tb_basic.py similarity index 88% rename from evennia/contrib/turnbattle.py rename to evennia/contrib/turnbattle/tb_basic.py index 692656bc3a..88e5c176c8 100644 --- a/evennia/contrib/turnbattle.py +++ b/evennia/contrib/turnbattle/tb_basic.py @@ -16,26 +16,26 @@ is easily extensible and can be used as the foundation for implementing the rules from your turn-based tabletop game of choice or making your own battle system. -To install and test, import this module's BattleCharacter object into +To install and test, import this module's TBBasicCharacter object into your game's character.py module: - from evennia.contrib.turnbattle import BattleCharacter + from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter -And change your game's character typeclass to inherit from BattleCharacter +And change your game's character typeclass to inherit from TBBasicCharacter instead of the default: - class Character(BattleCharacter): + class Character(TBBasicCharacter): Next, import this module into your default_cmdsets.py module: - from evennia.contrib import turnbattle + from evennia.contrib.turnbattle import tb_basic And add the battle command set to your default command set: # # any commands you add below will overload the default ones. # - self.add(turnbattle.BattleCmdSet()) + self.add(tb_basic.BattleCmdSet()) This module is meant to be heavily expanded on, so you may want to copy it to your game's 'world' folder and modify it there rather than importing it @@ -48,10 +48,18 @@ from evennia.commands.default.help import CmdHelp """ ---------------------------------------------------------------------------- -COMBAT FUNCTIONS START HERE +OPTIONS ---------------------------------------------------------------------------- """ +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" def roll_init(character): """ @@ -167,6 +175,20 @@ def apply_damage(defender, damage): if defender.db.hp <= 0: defender.db.hp = 0 +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) def resolve_attack(attacker, defender, attack_value=None, defense_value=None): """ @@ -195,10 +217,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) - # If defender HP is reduced to 0 or less, announce defeat. + # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: - attacker.location.msg_contents("%s has been defeated!" % defender) - + at_defeat(defender) def combat_cleanup(character): """ @@ -226,9 +247,7 @@ def is_in_combat(character): Returns: (bool): True if in combat or False if not in combat """ - if character.db.Combat_TurnHandler: - return True - return False + return bool(character.db.combat_turnhandler) def is_turn(character): @@ -241,11 +260,9 @@ def is_turn(character): Returns: (bool): True if it is their turn or False otherwise """ - turnhandler = character.db.Combat_TurnHandler + turnhandler = character.db.combat_turnhandler currentchar = turnhandler.db.fighters[turnhandler.db.turn] - if character == currentchar: - return True - return False + return bool(character == currentchar) def spend_action(character, actions, action_name=None): @@ -261,14 +278,14 @@ def spend_action(character, actions, action_name=None): combat to provided string """ if action_name: - character.db.Combat_LastAction = action_name + character.db.combat_lastaction = action_name if actions == 'all': # If spending all actions - character.db.Combat_ActionsLeft = 0 # Set actions to 0 + character.db.combat_actionsleft = 0 # Set actions to 0 else: - character.db.Combat_ActionsLeft -= actions # Use up actions. - if character.db.Combat_ActionsLeft < 0: - character.db.Combat_ActionsLeft = 0 # Can't have fewer than 0 actions - character.db.Combat_TurnHandler.turn_end_check(character) # Signal potential end of turn. + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. """ @@ -278,7 +295,7 @@ CHARACTER TYPECLASS """ -class BattleCharacter(DefaultCharacter): +class TBBasicCharacter(DefaultCharacter): """ A character able to participate in turn-based combat. Has attributes for current and maximum HP, and access to combat commands. @@ -324,7 +341,182 @@ class BattleCharacter(DefaultCharacter): return False return True +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + +class TBBasicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + """ ---------------------------------------------------------------------------- COMMANDS START HERE @@ -365,13 +557,13 @@ class CmdFight(Command): if len(fighters) <= 1: # If you're the only able fighter in the room self.caller.msg("There's nobody here to fight!") return - if here.db.Combat_TurnHandler: # If there's already a fight going on... + if here.db.combat_turnhandler: # If there's already a fight going on... here.msg_contents("%s joins the fight!" % self.caller) - here.db.Combat_TurnHandler.join_fight(self.caller) # Join the fight! + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. - here.scripts.add("contrib.turnbattle.TurnHandler") + here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler") # Remember you'll have to change the path to the script if you copy this code to your own modules! @@ -559,177 +751,4 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdRest()) self.add(CmdPass()) self.add(CmdDisengage()) - self.add(CmdCombatHelp()) - - -""" ----------------------------------------------------------------------------- -SCRIPTS START HERE ----------------------------------------------------------------------------- -""" - - -class TurnHandler(DefaultScript): - """ - This is the script that handles the progression of combat through turns. - On creation (when a fight is started) it adds all combat-ready characters - to its roster and then sorts them into a turn order. There can only be one - fight going on in a single room at a time, so the script is assigned to a - room as its object. - - Fights persist until only one participant is left with any HP or all - remaining participants choose to end the combat with the 'disengage' command. - """ - - def at_script_creation(self): - """ - Called once, when the script is created. - """ - self.key = "Combat Turn Handler" - self.interval = 5 # Once every 5 seconds - self.persistent = True - self.db.fighters = [] - - # Add all fighters in the room with at least 1 HP to the combat." - for object in self.obj.contents: - if object.db.hp: - self.db.fighters.append(object) - - # Initialize each fighter for combat - for fighter in self.db.fighters: - self.initialize_for_combat(fighter) - - # Add a reference to this script to the room - self.obj.db.Combat_TurnHandler = self - - # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. - # The initiative roll is determined by the roll_init function and can be customized easily. - ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) - self.db.fighters = ordered_by_roll - - # Announce the turn order. - self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - - # Set up the current turn and turn timeout delay. - self.db.turn = 0 - self.db.timer = 30 # 30 seconds - - def at_stop(self): - """ - Called at script termination. - """ - for fighter in self.db.fighters: - combat_cleanup(fighter) # Clean up the combat attributes for every fighter. - self.obj.db.Combat_TurnHandler = None # Remove reference to turn handler in location - - def at_repeat(self): - """ - Called once every self.interval seconds. - """ - currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. - self.db.timer -= self.interval # Count down the timer. - - if self.db.timer <= 0: - # Force current character to disengage if timer runs out. - self.obj.msg_contents("%s's turn timed out!" % currentchar) - spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. - return - elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left - # Warn the current character if they're about to time out. - currentchar.msg("WARNING: About to time out!") - self.db.timeout_warning_given = True - - def initialize_for_combat(self, character): - """ - Prepares a character for combat when starting or entering a fight. - - Args: - character (obj): Character to initialize for combat. - """ - combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. - character.db.Combat_ActionsLeft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 - character.db.Combat_TurnHandler = self # Add a reference to this turn handler script to the character - character.db.Combat_LastAction = "null" # Track last action taken in combat - - def start_turn(self, character): - """ - Readies a character for the start of their turn by replenishing their - available actions and notifying them that their turn has come up. - - Args: - character (obj): Character to be readied. - - Notes: - Here, you only get one action per turn, but you might want to allow more than - one per turn, or even grant a number of actions based on a character's - attributes. You can even add multiple different kinds of actions, I.E. actions - separated for movement, by adding "character.db.Combat_MovesLeft = 3" or - something similar. - """ - character.db.Combat_ActionsLeft = 1 # 1 action per turn. - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) - - def next_turn(self): - """ - Advances to the next character in the turn order. - """ - - # Check to see if every character disengaged as their last action. If so, end combat. - disengage_check = True - for fighter in self.db.fighters: - if fighter.db.Combat_LastAction != "disengage": # If a character has done anything but disengage - disengage_check = False - if disengage_check: # All characters have disengaged - self.obj.msg_contents("All fighters have disengaged! Combat is over!") - self.stop() # Stop this script and end combat. - return - - # Check to see if only one character is left standing. If so, end combat. - defeated_characters = 0 - for fighter in self.db.fighters: - if fighter.db.HP == 0: - defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) - if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated - for fighter in self.db.fighters: - if fighter.db.HP != 0: - LastStanding = fighter # Pick the one fighter left with HP remaining - self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) - self.stop() # Stop this script and end combat. - return - - # Cycle to the next turn. - currentchar = self.db.fighters[self.db.turn] - self.db.turn += 1 # Go to the next in the turn order. - if self.db.turn > len(self.db.fighters) - 1: - self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - newchar = self.db.fighters[self.db.turn] # Note the new character - self.db.timer = 30 + self.time_until_next_repeat() # Reset the timer. - self.db.timeout_warning_given = False # Reset the timeout warning. - self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) - self.start_turn(newchar) # Start the new character's turn. - - def turn_end_check(self, character): - """ - Tests to see if a character's turn is over, and cycles to the next turn if it is. - - Args: - character (obj): Character to test for end of turn - """ - if not character.db.Combat_ActionsLeft: # Character has no actions remaining - self.next_turn() - return - - def join_fight(self, character): - """ - Adds a new character to a fight already in progress. - - Args: - character (obj): Character to be added to the fight. - """ - # Inserts the fighter to the turn order, right behind whoever's turn it currently is. - self.db.fighters.insert(self.db.turn, character) - # Tick the turn counter forward one to compensate. - self.db.turn += 1 - # Initialize the character like you do at the start. - self.initialize_for_combat(character) + self.add(CmdCombatHelp()) \ No newline at end of file diff --git a/evennia/contrib/turnbattle/tb_equip.py b/evennia/contrib/turnbattle/tb_equip.py new file mode 100644 index 0000000000..b6b7ae6035 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_equip.py @@ -0,0 +1,1086 @@ +""" +Simple turn-based combat system with equipment + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib with a basic system for +weapons and armor implemented. Weapons can have unique damage ranges +and accuracy modifiers, while armor can reduce incoming damage and +change one's chance of getting hit. The 'wield' command is used to +equip weapons and the 'don' command is used to equip armor. + +Some prototypes are included at the end of this module - feel free to +copy them into your game's prototypes.py module in your 'world' folder +and create them with the @spawn command. (See the tutorial for using +the @spawn command for details.) + +For the example equipment given, heavier weapons deal more damage +but are less accurate, while light weapons are more accurate but +deal less damage. Similarly, heavy armor reduces incoming damage by +a lot but increases your chance of getting hit, while light armor is +easier to dodge in but reduces incoming damage less. Light weapons are +more effective against lightly armored opponents and heavy weapons are +more damaging against heavily armored foes, but heavy weapons and armor +are slightly better than light weapons and armor overall. + +This is a fairly bare implementation of equipment that is meant to be +expanded to fit your game - weapon and armor slots, damage types and +damage bonuses, etc. should be fairly simple to implement according to +the rules of your preferred system or the needs of your own game. + +To install and test, import this module's TBEquipCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_equip import TBEquipCharacter + +And change your game's character typeclass to inherit from TBEquipCharacter +instead of the default: + + class Character(TBEquipCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_equip + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_equip.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, DefaultObject +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + In this example, a weapon's accuracy bonus is factored into the attack + roll. Lighter weapons are more accurate but less damaging, and heavier + weapons are less accurate but deal more damage. Of course, you can + change this paradigm completely in your own game. + """ + # Start with a roll from 1 to 100. + attack_value = randint(1, 100) + accuracy_bonus = 0 + # If armed, add weapon's accuracy bonus. + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + accuracy_bonus += weapon.db.accuracy_bonus + # If unarmed, use character's unarmed accuracy bonus. + else: + accuracy_bonus += attacker.db.unarmed_accuracy + # Add the accuracy bonus to the attack roll. + attack_value += accuracy_bonus + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + Characters are given a default defense value of 50 which can be + modified up or down by armor. In this example, wearing armor actually + makes you a little easier to hit, but reduces incoming damage. + """ + # Start with a defense value of 50 for a 50/50 chance to hit. + defense_value = 50 + # Modify this value based on defender's armor. + if defender.db.worn_armor: + armor = defender.db.worn_armor + defense_value += armor.db.defense_modifier + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + Damage is determined by the attacker's wielded weapon, or the attacker's + unarmed damage range if no weapon is wielded. Incoming damage is reduced + by the defender's armor. + """ + damage_value = 0 + # Generate a damage value from wielded weapon if armed + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + # Roll between minimum and maximum damage + damage_value = randint(weapon.db.damage_range[0], weapon.db.damage_range[1]) + # Use attacker's unarmed damage otherwise + else: + damage_value = randint(attacker.db.unarmed_damage_range[0], attacker.db.unarmed_damage_range[1]) + # If defender is armored, reduce incoming damage + if defender.db.worn_armor: + armor = defender.db.worn_armor + damage_value -= armor.db.damage_reduction + # Make sure minimum damage is 0 + if damage_value < 0: + damage_value = 0 + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get the attacker's weapon type to reference in combat messages. + attackers_weapon = "attack" + if attacker.db.wielded_weapon: + weapon = attacker.db.wielded_weapon + attackers_weapon = weapon.db.weapon_type_name + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s misses %s!" % (attacker, attackers_weapon, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + if damage_value > 0: + attacker.location.msg_contents("%s's %s strikes %s for %i damage!" % (attacker, attackers_weapon, defender, damage_value)) + else: + attacker.location.msg_contents("%s's %s bounces harmlessly off %s!" % (attacker, attackers_weapon, defender)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBEquipTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + +class TBEWeapon(DefaultObject): + """ + A weapon which can be wielded in combat with the 'wield' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_range = (15, 25) # Minimum and maximum damage on hit + self.db.accuracy_bonus = 0 # Bonus to attack rolls (or penalty if negative) + self.db.weapon_type_name = "weapon" # Single word for weapon - I.E. "dagger", "staff", "scimitar" + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.wielded_weapon == self: + dropper.db.wielded_weapon = None + dropper.location.msg_contents("%s stops wielding %s." % (dropper, self)) + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.wielded_weapon == self: + giver.db.wielded_weapon = None + giver.location.msg_contents("%s stops wielding %s." % (giver, self)) + +class TBEArmor(DefaultObject): + """ + A set of armor which can be worn with the 'don' command. + """ + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.damage_reduction = 4 # Amount of incoming damage reduced by armor + self.db.defense_modifier = -4 # Amount to modify defense value (pos = harder to hit, neg = easier) + def at_before_drop(self, dropper): + """ + Can't drop in combat. + """ + if is_in_combat(dropper): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_drop(self, dropper): + """ + Stop being wielded if dropped. + """ + if dropper.db.worn_armor == self: + dropper.db.worn_armor = None + dropper.location.msg_contents("%s removes %s." % (dropper, self)) + def at_before_give(self, giver, getter): + """ + Can't give away in combat. + """ + if is_in_combat(giver): + dropper.msg("You can't doff armor in a fight!") + return False + return True + def at_give(self, giver, getter): + """ + Stop being wielded if given. + """ + if giver.db.worn_armor == self: + giver.db.worn_armor = None + giver.location.msg_contents("%s removes %s." % (giver, self)) + +class TBEquipCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.wielded_weapon = None # Currently used weapon + self.db.worn_armor = None # Currently worn armor + self.db.unarmed_damage_range = (5, 15) # Minimum and maximum unarmed damage + self.db.unarmed_accuracy = 30 # Accuracy bonus for unarmed attacks + + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_equip.TBEquipTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + +class CmdWield(Command): + """ + Wield a weapon you are carrying + + Usage: + wield + + Select a weapon you are carrying to wield in combat. If + you are already wielding another weapon, you will switch + to the weapon you specify instead. Using this command in + combat will spend your action for your turn. Use the + "unwield" command to stop wielding any weapon you are + currently wielding. + """ + + key = "wield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.args: + self.caller.msg("Usage: wield ") + return + weapon = self.caller.search(self.args, candidates=self.caller.contents) + if not weapon: + return + if not weapon.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEWeapon"): + self.caller.msg("That's not a weapon!") + # Remember to update the path to the weapon typeclass if you move this module! + return + + if not self.caller.db.wielded_weapon: + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s wields %s." % (self.caller, weapon)) + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = weapon + self.caller.location.msg_contents("%s lowers %s and wields %s." % (self.caller, old_weapon, weapon)) + # Spend an action if in combat. + if is_in_combat(self.caller): + spend_action(self.caller, 1, action_name="wield") # Use up one action. + +class CmdUnwield(Command): + """ + Stop wielding a weapon. + + Usage: + unwield + + After using this command, you will stop wielding any + weapon you are currently wielding and become unarmed. + """ + + key = "unwield" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # If in combat, check to see if it's your turn. + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only do that on your turn.") + return + if not self.caller.db.wielded_weapon: + self.caller.msg("You aren't wielding a weapon!") + else: + old_weapon = self.caller.db.wielded_weapon + self.caller.db.wielded_weapon = None + self.caller.location.msg_contents("%s lowers %s." % (self.caller, old_weapon)) + +class CmdDon(Command): + """ + Don armor that you are carrying + + Usage: + don + + Select armor to wear in combat. You can't use this + command in the middle of a fight. Use the "doff" + command to remove any armor you are wearing. + """ + + key = "don" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't don armor in a fight!") + return + if not self.args: + self.caller.msg("Usage: don ") + return + armor = self.caller.search(self.args, candidates=self.caller.contents) + if not armor: + return + if not armor.is_typeclass("evennia.contrib.turnbattle.tb_equip.TBEArmor"): + self.caller.msg("That's not armor!") + # Remember to update the path to the armor typeclass if you move this module! + return + + if not self.caller.db.worn_armor: + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s dons %s." % (self.caller, armor)) + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = armor + self.caller.location.msg_contents("%s removes %s and dons %s." % (self.caller, old_armor, armor)) + +class CmdDoff(Command): + """ + Stop wearing armor. + + Usage: + doff + + After using this command, you will stop wearing any + armor you are currently using and become unarmored. + You can't use this command in combat. + """ + + key = "doff" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + # Can't do this in combat + if is_in_combat(self.caller): + self.caller.msg("You can't doff armor in a fight!") + return + if not self.caller.db.worn_armor: + self.caller.msg("You aren't wearing any armor!") + else: + old_armor = self.caller.db.worn_armor + self.caller.db.worn_armor = None + self.caller.location.msg_contents("%s removes %s." % (self.caller, old_armor)) + + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdWield()) + self.add(CmdUnwield()) + self.add(CmdDon()) + self.add(CmdDoff()) + +""" +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- +""" + +BASEWEAPON = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEWeapon", +} + +BASEARMOR = { + "typeclass": "evennia.contrib.turnbattle.tb_equip.TBEArmor", +} + +DAGGER = { + "prototype" : "BASEWEAPON", + "damage_range" : (10, 20), + "accuracy_bonus" : 30, + "key": "a thin steel dagger", + "weapon_type_name" : "dagger" +} + +BROADSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (15, 30), + "accuracy_bonus" : 15, + "key": "an iron broadsword", + "weapon_type_name" : "broadsword" +} + +GREATSWORD = { + "prototype" : "BASEWEAPON", + "damage_range" : (20, 40), + "accuracy_bonus" : 0, + "key": "a rune-etched greatsword", + "weapon_type_name" : "greatsword" +} + +LEATHERARMOR = { + "prototype" : "BASEARMOR", + "damage_reduction" : 2, + "defense_modifier" : -2, + "key": "a suit of leather armor" +} + +SCALEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 4, + "defense_modifier" : -4, + "key": "a suit of scale mail" +} + +PLATEMAIL = { + "prototype" : "BASEARMOR", + "damage_reduction" : 6, + "defense_modifier" : -6, + "key": "a suit of plate mail" +} diff --git a/evennia/contrib/turnbattle/tb_range.py b/evennia/contrib/turnbattle/tb_range.py new file mode 100644 index 0000000000..5596573a2d --- /dev/null +++ b/evennia/contrib/turnbattle/tb_range.py @@ -0,0 +1,1383 @@ +""" +Simple turn-based combat system with range and movement + +Contrib - Tim Ashley Jenkins 2017 + +This is a version of the 'turnbattle' contrib that includes a system +for abstract movement and positioning in combat, including distinction +between melee and ranged attacks. In this system, a fighter or object's +exact position is not recorded - only their relative distance to other +actors in combat. + +In this example, the distance between two objects in combat is expressed +as an integer value: 0 for "engaged" objects that are right next to each +other, 1 for "reach" which is for objects that are near each other but +not directly adjacent, and 2 for "range" for objects that are far apart. + +When combat starts, all fighters are at reach with each other and other +objects, and at range from any exits. On a fighter's turn, they can use +the "approach" command to move closer to an object, or the "withdraw" +command to move further away from an object, either of which takes an +action in combat. In this example, fighters are given two actions per +turn, allowing them to move and attack in the same round, or to attack +twice or move twice. + +When you move toward an object, you will also move toward anything else +that's close to your target - the same goes for moving away from a target, +which will also move you away from anything close to your target. Moving +toward one target may also move you away from anything you're already +close to, but withdrawing from a target will never inadvertently bring +you closer to anything else. + +In this example, there are two attack commands. 'Attack' can only hit +targets that are 'engaged' (range 0) with you. 'Shoot' can hit any target +on the field, but cannot be used if you are engaged with any other fighters. +In addition, strikes made with the 'attack' command are more accurate than +'shoot' attacks. This is only to provide an example of how melee and ranged +attacks can be made to work differently - you can, of course, modify this +to fit your rules system. + +When in combat, the ranges of objects are also accounted for - you can't +pick up an object unless you're engaged with it, and can't give an object +to another fighter without being engaged with them either. Dropped objects +are automatically assigned a range of 'engaged' with the fighter who dropped +them. Additionally, giving or getting an object will take an action in combat. +Dropping an object does not take an action, but can only be done on your turn. + +When combat ends, all range values are erased and all restrictions on getting +or getting objects are lifted - distances are no longer tracked and objects in +the same room can be considered to be in the same space, as is the default +behavior of Evennia and most MUDs. + +This system allows for strategies in combat involving movement and +positioning to be implemented in your battle system without the use of +a 'grid' of coordinates, which can be difficult and clunky to navigate +in text and disadvantageous to players who use screen readers. This loose, +narrative method of tracking position is based around how the matter is +handled in tabletop RPGs played without a grid - typically, a character's +exact position in a room isn't important, only their relative distance to +other actors. + +You may wish to expand this system with a method of distinguishing allies +from enemies (to prevent allied characters from blocking your ranged attacks) +as well as some method by which melee-focused characters can prevent enemies +from withdrawing or punish them from doing so, such as by granting "attacks of +opportunity" or something similar. If you wish, you can also expand the breadth +of values allowed for range - rather than just 0, 1, and 2, you can allow ranges +to go up to much higher values, and give attacks and movements more varying +values for distance for a more granular system. You may also want to implement +a system for fleeing or changing rooms in combat by approaching exits, which +are objects placed in the range field like any other. + +To install and test, import this module's TBRangeCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_range import TBRangeCharacter + +And change your game's character typeclass to inherit from TBRangeCharacter +instead of the default: + + class Character(TBRangeCharacter): + +Do the same thing in your game's objects.py module for TBRangeObject: + + from evennia.contrib.turnbattle.tb_range import TBRangeObject + class Object(TBRangeObject): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_range + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_range.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, DefaultObject, Command, default_cmds, DefaultScript +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 2 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender, attack_type): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, generates a random integer from 1 to 100 without using any + properties from either the attacker or defender, and modifies the result + based on whether it's for a melee or ranged attack. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + # Make melee attacks more accurate, ranged attacks less accurate + if attack_type == "melee": + attack_value += 15 + if attack_type == "ranged": + attack_value -= 15 + return attack_value + + +def get_defense(attacker, defender, attack_type): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack ('melee' or 'ranged') + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_type, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + attack_type (str): Type of attack (melee or ranged) + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender, attack_type) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender, attack_type) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's %s attack misses %s!" % (attacker, attack_type, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s with a %s attack for %i damage!" % (attacker, defender, attack_type, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def get_range(obj1, obj2): + """ + Gets the combat range between two objects. + + Args: + obj1 (obj): First object + obj2 (obj): Second object + + Returns: + range (int or None): Distance between two objects or None if not applicable + """ + # Return None if not applicable. + if not obj1.db.combat_range: + return None + if not obj2.db.combat_range: + return None + if obj1 not in obj2.db.combat_range: + return None + if obj2 not in obj1.db.combat_range: + return None + # Return the range between the two objects. + return obj1.db.combat_range[obj2] + +def distance_inc(mover, target): + """ + Function that increases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + """ + mover.db.combat_range[target] += 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # Set a cap of 2: + if get_range(mover, target) > 2: + target.db.combat_range[mover] = 2 + mover.db.combat_range[target] = 2 + +def approach(mover, target): + """ + Manages a character's whole approach, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + + Notes: + The mover will also automatically move toward any objects that are closer to the + target than the mover is. The mover will also move away from anything they started + out close to. + """ + def distance_dec(mover, target): + """ + Helper function that decreases distance in range field between mover and target. + + Args: + mover (obj): The object moving + target (obj): The object to be moved toward + """ + mover.db.combat_range[target] -= 1 + target.db.combat_range[mover] = mover.db.combat_range[target] + # If this brings mover to range 0 (Engaged): + if get_range(mover, target) <= 0: + # Reset range to each other to 0 and copy target's ranges to mover. + target.db.combat_range[mover] = 0 + mover.db.combat_range = target.db.combat_range + # Assure everything else has the same distance from the mover and target, now that they're together + for thing in mover.location.contents: + if thing != mover and thing != target: + thing.db.combat_range[mover] = thing.db.combat_range[target] + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move closer to each object closer to the target than you. + if get_range(mover, thing) > get_range(target, thing): + distance_dec(mover, thing) + # Move further from each object that's further from you than from the target. + if get_range(mover, thing) < get_range(target, thing): + distance_inc(mover, thing) + # Lastly, move closer to your target. + distance_dec(mover, target) + +def withdraw(mover, target): + """ + Manages a character's whole withdrawal, including changes in ranges to other characters. + + Args: + mover (obj): The object moving + target (obj): The object to be moved away from + + Notes: + The mover will also automatically move away from objects that are close to the target + of their withdrawl. The mover will never inadvertently move toward anything else while + withdrawing - they can be considered to be moving to open space. + """ + + contents = mover.location.contents + + for thing in contents: + if thing != mover and thing != target: + # Move away from each object closer to the target than you, if it's also closer to you than you are to the target. + if get_range(mover, thing) >= get_range(target, thing) and get_range(mover, thing) < get_range(mover, target): + distance_inc(mover, thing) + # Move away from anything your target is engaged with + if get_range(target, thing) == 0: + distance_inc(mover, thing) + # Move away from anything you're engaged with. + if get_range(mover, thing) == 0: + distance_inc(mover, thing) + # Then, move away from your target. + distance_inc(mover, target) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. + +def combat_status_message(fighter): + """ + Sends a message to a player with their current HP and + distances to other fighters and objects. Called at turn + start and by the 'status' command. + """ + if not fighter.db.max_hp: + fighter.db.hp = 100 + fighter.db.max_hp = 100 + + status_msg = ("HP Remaining: %i / %i" % (fighter.db.hp, fighter.db.max_hp)) + + if not is_in_combat(fighter): + fighter.msg(status_msg) + return + + engaged_obj = [] + reach_obj = [] + range_obj = [] + + for thing in fighter.db.combat_range: + if thing != fighter: + if fighter.db.combat_range[thing] == 0: + engaged_obj.append(thing) + if fighter.db.combat_range[thing] == 1: + reach_obj.append(thing) + if fighter.db.combat_range[thing] > 1: + range_obj.append(thing) + + if engaged_obj: + status_msg += "|/Engaged targets: %s" % ", ".join(obj.key for obj in engaged_obj) + if reach_obj: + status_msg += "|/Reach targets: %s" % ", ".join(obj.key for obj in reach_obj) + if range_obj: + status_msg += "|/Ranged targets: %s" % ", ".join(obj.key for obj in range_obj) + + fighter.msg(status_msg) + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Initialize range field for all objects in the room + for thing in self.obj.contents: + self.init_range(thing) + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for thing in self.obj.contents: + combat_cleanup(thing) # Clean up the combat attributes for every object in the room. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def init_range(self, to_init): + """ + Initializes range values for an object at the start of a fight. + + Args: + to_init (object): Object to initialize range field for. + """ + rangedict = {} + # Get a list of objects in the room. + objectlist = self.obj.contents + for thing in objectlist: + # Object always at distance 0 from itself + if thing == to_init: + rangedict.update({thing:0}) + else: + if thing.destination or to_init.destination: + # Start exits at range 2 to put them at the 'edges' + rangedict.update({thing:2}) + else: + # Start objects at range 1 from other objects + rangedict.update({thing:1}) + to_init.db.combat_range = rangedict + + def join_rangefield(self, to_init, anchor_obj=None, add_distance=0): + """ + Adds a new object to the range field of a fight in progress. + + Args: + to_init (object): Object to initialize range field for. + + Kwargs: + anchor_obj (object): Object to copy range values from, or None for a random object. + add_distance (int): Distance to put between to_init object and anchor object. + + """ + # Get a list of room's contents without to_init object. + contents = self.obj.contents + contents.remove(to_init) + # If no anchor object given, pick one in the room at random. + if not anchor_obj: + anchor_obj = contents[randint(0, (len(contents)-1))] + # Copy the range values from the anchor object. + to_init.db.combat_range = anchor_obj.db.combat_range + # Add the new object to everyone else's ranges. + for thing in contents: + new_objects_range = thing.db.combat_range[anchor_obj] + thing.db.combat_range.update({to_init:new_objects_range}) + # Set the new object's range to itself to 0. + to_init.db.combat_range.update({to_init:0}) + # Add additional distance from anchor object, if any. + for n in range(add_distance): + withdraw(to_init, anchor_obj) + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + In this example, characters are given two actions per turn. This allows + characters to both move and attack in the same turn (or, alternately, + move twice or attack twice). + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn!|n") + combat_status_message(character) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + # Add the character to the rangefield, at range from everyone, if they're not on it already. + if not character.db.combat_range: + self.join_rangefield(character, add_distance=2) + + +""" +---------------------------------------------------------------------------- +TYPECLASSES START HERE +---------------------------------------------------------------------------- +""" + + +class TBRangeCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + +class TBRangeObject(DefaultObject): + """ + An object that is assigned range values in combat. Getting, giving, and dropping + the object has restrictions in combat - you must be next to an object to get it, + must be next to your target to give them something, and can only interact with + objects on your own turn. + """ + def at_before_drop(self, dropper): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + # Can't drop something if in combat and it's not your turn + if is_in_combat(dropper) and not is_turn(dropper): + dropper.msg("You can only drop things on your turn!") + return False + return True + + def at_drop(self, dropper): + """ + Called by the default `drop` command when this object has been + dropped. + + Args: + dropper (Object): The object which just dropped this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the drop from happening. Use + permissions or the at_before_drop() hook for that. + + """ + # If dropper is currently in combat + if dropper.location.db.combat_turnhandler: + # Object joins the range field + self.db.combat_range = {} + dropper.location.db.combat_turnhandler.join_rangefield(self, anchor_obj=dropper) + + def at_before_get(self, getter): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + # Restrictions for getting in combat + if is_in_combat(getter): + if not is_turn(getter): # Not your turn + getter.msg("You can only get things on your turn!") + return False + if get_range(self, getter) > 0: # Too far away + getter.msg("You aren't close enough to get that! (see: help approach)") + return False + return True + + def at_get(self, getter): + """ + Called by the default `get` command when this object has been + picked up. + + Args: + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the pickup from happening. Use + permissions or the at_before_get() hook for that. + + """ + # If gotten, erase range values + if self.db.combat_range: + del self.db.combat_range + # Remove this object from everyone's range fields + for thing in getter.location.contents: + if thing.db.combat_range: + if self in thing.db.combat_range: + thing.db.combat_range.pop(self, None) + # If in combat, getter spends an action + if is_in_combat(getter): + spend_action(getter, 1, action_name="get") # Use up one action. + + def at_before_give(self, giver, getter): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + # Restrictions for giving in combat + if is_in_combat(giver): + if not is_turn(giver): # Not your turn + giver.msg("You can only give things on your turn!") + return False + if get_range(giver, getter) > 0: # Too far away from target + giver.msg("You aren't close enough to give things to %s! (see: help approach)" % getter) + return False + return True + + def at_give(self, giver, getter): + """ + Called by the default `give` command when this object has been + given. + + Args: + giver (Object): The object giving this object. + getter (Object): The object getting this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This hook cannot stop the give from happening. Use + permissions or the at_before_give() hook for that. + + """ + # Spend an action if in combat + if is_in_combat(giver): + spend_action(giver, 1, action_name="give") # Use up one action. + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_range.TBRangeTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character in melee. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. You can only + attack engaged targets - that is, targets that are right next to + you. Use the 'approach' command to get closer to a target. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + if not get_range(attacker, defender) == 0: # Target isn't in melee + self.caller.msg("%s is too far away to attack - you need to get closer! (see: help approach)" % defender) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "melee") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdShoot(Command): + """ + Attacks another character from range. + + Usage: + shoot + + When in a fight, you may shoot another character. The attack has + a chance to hit, and if successful, will deal damage. You can attack + any target in combat by shooting, but can't shoot if there are any + targets engaged with you. Use the 'withdraw' command to retreat from + nearby enemies. + """ + + key = "shoot" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + # Test to see if there are any nearby enemy targets. + in_melee = [] + for target in attacker.db.combat_range: + # Object is engaged and has HP + if get_range(attacker, defender) == 0 and target.db.hp and target != self.caller: + in_melee.append(target) # Add to list of targets in melee + + if len(in_melee) > 0: + self.caller.msg("You can't shoot because there are fighters engaged with you (%s) - you need to retreat! (see: help withdraw)" % ", ".join(obj.key for obj in in_melee)) + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender, "ranged") + spend_action(self.caller, 1, action_name="attack") # Use up one action. + +class CmdApproach(Command): + """ + Approaches an object. + + Usage: + approach + + Move one space toward a character or object. You can only attack + characters you are 0 spaces away from. + """ + + key = "approach" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't approach. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't approach. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't approach if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move toward that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move toward yourself!") + return + + if get_range(mover, target) <= 0: # Already engaged with target + self.caller.msg("You're already next to that target!") + return + + # If everything checks out, call the approach resolving function. + approach(mover, target) + mover.location.msg_contents("%s moves toward %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + +class CmdWithdraw(Command): + """ + Moves away from an object. + + Usage: + withdraw + + Move one space away from a character or object. + """ + + key = "withdraw" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if not is_in_combat(self.caller): # If not in combat, can't withdraw. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't withdraw. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't withdraw if you have no HP. + self.caller.msg("You can't move, you've been defeated.") + return + + mover = self.caller + target = self.caller.search(self.args) + + if not target: # No valid target given. + return + + if not target.db.combat_range: # Target object is not on the range field + self.caller.msg("You can't move away from that!") + return + + if mover == target: # Target and mover are the same + self.caller.msg("You can't move away from yourself!") + return + + if mover.db.combat_range[target] >= 3: # Already at maximum distance + self.caller.msg("You're as far as you can get from that target!") + return + + # If everything checks out, call the approach resolving function. + withdraw(mover, target) + mover.location.msg_contents("%s moves away from %s." % (mover, target)) + spend_action(self.caller, 1, action_name="move") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + +class CmdStatus(Command): + """ + Gives combat information. + + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + def func(self): + "This performs the actual command." + combat_status_message(self.caller) + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack an engaged target, attempting to deal damage.|/" + + "|wShoot:|n Attack from a distance, if not engaged with other fighters.|/" + + "|wApproach:|n Move one step cloer to a target.|/" + + "|wWithdraw:|n Move one step away from a target.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wStatus:|n View current HP and ranges to other targets.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdShoot()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdApproach()) + self.add(CmdWithdraw()) + self.add(CmdStatus()) + self.add(CmdCombatHelp()) \ No newline at end of file diff --git a/evennia/game_template/server/conf/settings.py b/evennia/game_template/server/conf/settings.py index 7fe163b833..a8f9776805 100644 --- a/evennia/game_template/server/conf/settings.py +++ b/evennia/game_template/server/conf/settings.py @@ -34,27 +34,6 @@ from evennia.settings_default import * # This is the name of your game. Make it catchy! SERVERNAME = {servername} -# Server ports. If enabled and marked as "visible", the port -# should be visible to the outside world on a production server. -# Note that there are many more options available beyond these. - -# Telnet ports. Visible. -TELNET_ENABLED = True -TELNET_PORTS = [4000] -# (proxy, internal). Only proxy should be visible. -WEBSERVER_ENABLED = True -WEBSERVER_PORTS = [(4001, 4002)] -# Telnet+SSL ports, for supporting clients. Visible. -SSL_ENABLED = False -SSL_PORTS = [4003] -# SSH client ports. Requires crypto lib. Visible. -SSH_ENABLED = False -SSH_PORTS = [4004] -# Websocket-client port. Visible. -WEBSOCKET_CLIENT_ENABLED = True -WEBSOCKET_CLIENT_PORT = 4005 -# Internal Server-Portal port. Not visible. -AMP_PORT = 4006 ###################################################################### # Settings given in secret_settings.py override those in this file. @@ -62,4 +41,4 @@ AMP_PORT = 4006 try: from server.conf.secret_settings import * except ImportError: - print "secret_settings.py file not found or failed to import." + print("secret_settings.py file not found or failed to import.") diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index a588af0000..f10338b76b 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -6,8 +6,10 @@ entities. """ import time +import inflect from builtins import object from future.utils import with_metaclass +from collections import defaultdict from django.conf import settings @@ -22,9 +24,11 @@ from evennia.commands import cmdhandler from evennia.utils import search from evennia.utils import logger from evennia.utils.utils import (variable_from_module, lazy_property, - make_iter, to_unicode, is_iter, to_str) + make_iter, to_unicode, is_iter, list_to_string, + to_str) from django.utils.translation import ugettext as _ +_INFLECT = inflect.engine() _MULTISESSION_MODE = settings.MULTISESSION_MODE _ScriptDB = None @@ -289,9 +293,39 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): return "{}(#{})".format(self.name, self.id) return self.name + def get_numbered_name(self, count, looker, **kwargs): + """ + Return the numbered (singular, plural) forms of this object's key. This is by default called + by return_appearance and is used for grouping multiple same-named of this object. Note that + this will be called on *every* member of a group even though the plural name will be only + shown once. Also the singular display version, such as 'an apple', 'a tree' is determined + from this method. + + Args: + count (int): Number of objects of this type + looker (Object): Onlooker. Not used by default. + Kwargs: + key (str): Optional key to pluralize, use this instead of the object's key. + Returns: + singular (str): The singular form to display. + plural (str): The determined plural form of the key, including the count. + """ + key = kwargs.get("key", self.key) + plural = _INFLECT.plural(key, 2) + plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) + singular = _INFLECT.an(key) + if not self.aliases.get(plural, category="plural_key"): + # we need to wipe any old plurals/an/a in case key changed in the interrim + self.aliases.clear(category="plural_key") + self.aliases.add(plural, category="plural_key") + # save the singular form as an alias here too so we can display "an egg" and also + # look at 'an egg'. + self.aliases.add(singular, category="plural_key") + return singular, plural + def search(self, searchdata, global_search=False, - use_nicks=True, # should this default to off? + use_nicks=True, typeclass=None, location=None, attribute_name=None, @@ -1448,7 +1482,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # get and identify all objects visible = (con for con in self.contents if con != looker and con.access(looker, "view")) - exits, users, things = [], [], [] + exits, users, things = [], [], defaultdict(list) for con in visible: key = con.get_display_name(looker) if con.destination: @@ -1456,16 +1490,28 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): elif con.has_account: users.append("|c%s|n" % key) else: - things.append(key) + # things can be pluralized + things[key].append(con) # get description, build string string = "|c%s|n\n" % self.get_display_name(looker) desc = self.db.desc if desc: string += "%s" % desc if exits: - string += "\n|wExits:|n " + ", ".join(exits) + string += "\n|wExits:|n " + list_to_string(exits) if users or things: - string += "\n|wYou see:|n " + ", ".join(users + things) + # handle pluralization of things (never pluralize users) + thing_strings = [] + for key, itemlist in sorted(things.iteritems()): + nitem = len(itemlist) + if nitem == 1: + key, _ = itemlist[0].get_numbered_name(nitem, looker, key=key) + else: + key = [item.get_numbered_name(nitem, looker, key=key)[1] for item in itemlist][0] + thing_strings.append(key) + + string += "\n|wYou see:|n " + list_to_string(users + thing_strings) + return string def at_look(self, target, **kwargs): @@ -1512,6 +1558,25 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): """ pass + def at_before_get(self, getter, **kwargs): + """ + Called by the default `get` command before this object has been + picked up. + + Args: + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldget (bool): If the object should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled + before it is even started. + """ + return True + def at_get(self, getter, **kwargs): """ Called by the default `get` command when this object has been @@ -1524,11 +1589,32 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the pickup from happening. Use - permissions for that. + permissions or the at_before_get() hook for that. """ pass + def at_before_give(self, giver, getter, **kwargs): + """ + Called by the default `give` command before this object has been + given. + + Args: + giver (Object): The object about to give this object. + getter (Object): The object about to get this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shouldgive (bool): If the object should be given or not. + + Notes: + If this method returns False/None, the giving is cancelled + before it is even started. + + """ + return True + def at_give(self, giver, getter, **kwargs): """ Called by the default `give` command when this object has been @@ -1542,11 +1628,31 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the give from happening. Use - permissions for that. + permissions or the at_before_give() hook for that. """ pass + def at_before_drop(self, dropper, **kwargs): + """ + Called by the default `drop` command before this object has been + dropped. + + Args: + dropper (Object): The object which will drop this object. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + shoulddrop (bool): If the object should be dropped or not. + + Notes: + If this method returns False/None, the dropping is cancelled + before it is even started. + + """ + return True + def at_drop(self, dropper, **kwargs): """ Called by the default `drop` command when this object has been @@ -1559,7 +1665,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): Notes: This hook cannot stop the drop from happening. Use - permissions from that. + permissions or the at_before_drop() hook for that. """ pass @@ -1639,11 +1745,11 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # whisper mode msg_type = 'whisper' msg_self = '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self + msg_receivers = '{object} whispers: "{speech}"' msg_receivers = msg_receivers or '{object} whispers: "{speech}"' msg_location = None else: msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self - msg_receivers = None msg_location = msg_location or '{object} says, "{speech}"' custom_mapping = kwargs.get('mapping', {}) @@ -1689,9 +1795,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): "receiver": None, "speech": message} location_mapping.update(custom_mapping) + exclude = [] + if msg_self: + exclude.append(self) + if receivers: + exclude.extend(receivers) self.location.msg_contents(text=(msg_location, {"type": msg_type}), from_obj=self, - exclude=(self, ) if msg_self else None, + exclude=exclude, mapping=location_mapping) diff --git a/evennia/scripts/manager.py b/evennia/scripts/manager.py index 17a11d20b3..1b795b2c3e 100644 --- a/evennia/scripts/manager.py +++ b/evennia/scripts/manager.py @@ -214,7 +214,7 @@ class ScriptDBManager(TypedObjectManager): VALIDATE_ITERATION -= 1 return nr_started, nr_stopped - def search_script(self, ostring, obj=None, only_timed=False): + def search_script(self, ostring, obj=None, only_timed=False, typeclass=None): """ Search for a particular script. @@ -224,6 +224,7 @@ class ScriptDBManager(TypedObjectManager): this object only_timed (bool): Limit search only to scripts that run on a timer. + typeclass (class or str): Typeclass or path to typeclass. """ @@ -237,10 +238,17 @@ class ScriptDBManager(TypedObjectManager): (only_timed and dbref_match.interval)): return [dbref_match] + if typeclass: + if callable(typeclass): + typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclass = u"%s" % typeclass + # not a dbref; normal search obj_restriction = obj and Q(db_obj=obj) or Q() - timed_restriction = only_timed and Q(interval__gt=0) or Q() - scripts = self.filter(timed_restriction & obj_restriction & Q(db_key__iexact=ostring)) + timed_restriction = only_timed and Q(db_interval__gt=0) or Q() + typeclass_restriction = typeclass and Q(db_typeclass_path=typeclass) or Q() + scripts = self.filter(timed_restriction & obj_restriction & typeclass_restriction & Q(db_key__iexact=ostring)) return scripts # back-compatibility alias script_search = search_script diff --git a/evennia/server/amp.py b/evennia/server/amp.py deleted file mode 100644 index 9694abd034..0000000000 --- a/evennia/server/amp.py +++ /dev/null @@ -1,670 +0,0 @@ -""" -Contains the protocols, commands, and client factory needed for the Server -and Portal to communicate with each other, letting Portal work as a proxy. -Both sides use this same protocol. - -The separation works like this: - -Portal - (AMP client) handles protocols. It contains a list of connected - sessions in a dictionary for identifying the respective account - connected. If it loses the AMP connection it will automatically - try to reconnect. - -Server - (AMP server) Handles all mud operations. The server holds its own list - of sessions tied to account objects. This is synced against the portal - at startup and when a session connects/disconnects - -""" -from __future__ import print_function - -# imports needed on both server and portal side -import os -import time -from collections import defaultdict, namedtuple -from itertools import count -from cStringIO import StringIO -try: - import cPickle as pickle -except ImportError: - import pickle -from twisted.protocols import amp -from twisted.internet import protocol -from twisted.internet.defer import Deferred -from evennia.utils import logger -from evennia.utils.utils import to_str, variable_from_module -import zlib # Used in Compressed class - -DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0) - -# communication bits -# (chr(9) and chr(10) are \t and \n, so skipping them) - -PCONN = chr(1) # portal session connect -PDISCONN = chr(2) # portal session disconnect -PSYNC = chr(3) # portal session sync -SLOGIN = chr(4) # server session login -SDISCONN = chr(5) # server session disconnect -SDISCONNALL = chr(6) # server session disconnect all -SSHUTD = chr(7) # server shutdown -SSYNC = chr(8) # server session sync -SCONN = chr(11) # server creating new connection (for irc bots and etc) -PCONNSYNC = chr(12) # portal post-syncing a session -PDISCONNALL = chr(13) # portal session disconnect all -AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed) - -BATCH_RATE = 250 # max commands/sec before switching to batch-sending -BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds - -# buffers -_SENDBATCH = defaultdict(list) -_MSGBUFFER = defaultdict(list) - - -def get_restart_mode(restart_file): - """ - Parse the server/portal restart status - - Args: - restart_file (str): Path to restart.dat file. - - Returns: - restart_mode (bool): If the file indicates the server is in - restart mode or not. - - """ - if os.path.exists(restart_file): - flag = open(restart_file, 'r').read() - return flag == "True" - return False - - -class AmpServerFactory(protocol.ServerFactory): - """ - This factory creates the Server as a new AMPProtocol instance for accepting - connections from the Portal. - """ - noisy = False - - def __init__(self, server): - """ - Initialize the factory. - - Args: - server (Server): The Evennia server service instance. - protocol (Protocol): The protocol the factory creates - instances of. - - """ - self.server = server - self.protocol = AMPProtocol - - def buildProtocol(self, addr): - """ - Start a new connection, and store it on the service object. - - Args: - addr (str): Connection address. Not used. - - Returns: - protocol (Protocol): The created protocol. - - """ - self.server.amp_protocol = AMPProtocol() - self.server.amp_protocol.factory = self - return self.server.amp_protocol - - -class AmpClientFactory(protocol.ReconnectingClientFactory): - """ - This factory creates an instance of the Portal, an AMPProtocol - instances to use to connect - - """ - # Initial reconnect delay in seconds. - initialDelay = 1 - factor = 1.5 - maxDelay = 1 - noisy = False - - def __init__(self, portal): - """ - Initializes the client factory. - - Args: - portal (Portal): Portal instance. - - """ - self.portal = portal - self.protocol = AMPProtocol - - def startedConnecting(self, connector): - """ - Called when starting to try to connect to the MUD server. - - Args: - connector (Connector): Twisted Connector instance representing - this connection. - - """ - pass - - def buildProtocol(self, addr): - """ - Creates an AMPProtocol instance when connecting to the server. - - Args: - addr (str): Connection address. Not used. - - """ - self.resetDelay() - self.portal.amp_protocol = AMPProtocol() - self.portal.amp_protocol.factory = self - return self.portal.amp_protocol - - def clientConnectionLost(self, connector, reason): - """ - Called when the AMP connection to the MUD server is lost. - - Args: - connector (Connector): Twisted Connector instance representing - this connection. - reason (str): Eventual text describing why connection was lost. - - """ - if hasattr(self, "server_restart_mode"): - self.portal.sessions.announce_all(" Server restarting ...") - self.maxDelay = 2 - else: - # Don't translate this; avoid loading django on portal side. - self.maxDelay = 10 - self.portal.sessions.announce_all(" ... Portal lost connection to Server.") - protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason) - - def clientConnectionFailed(self, connector, reason): - """ - Called when an AMP connection attempt to the MUD server fails. - - Args: - connector (Connector): Twisted Connector instance representing - this connection. - reason (str): Eventual text describing why connection failed. - - """ - if hasattr(self, "server_restart_mode"): - self.maxDelay = 2 - else: - self.maxDelay = 10 - self.portal.sessions.announce_all(" ...") - protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) - - -# AMP Communication Command types - -class Compressed(amp.String): - """ - This is a customn AMP command Argument that both handles too-long - sends as well as uses zlib for compression across the wire. The - batch-grouping of too-long sends is borrowed from the "mediumbox" - recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox. - - """ - - def fromBox(self, name, strings, objects, proto): - """ - Converts from box representation to python. We - group very long data into batches. - """ - value = StringIO() - value.write(strings.get(name)) - for counter in count(2): - # count from 2 upwards - chunk = strings.get("%s.%d" % (name, counter)) - if chunk is None: - break - value.write(chunk) - objects[name] = value.getvalue() - - def toBox(self, name, strings, objects, proto): - """ - Convert from data to box. We handled too-long - batched data and put it together here. - """ - value = StringIO(objects[name]) - strings[name] = value.read(AMP_MAXLEN) - for counter in count(2): - chunk = value.read(AMP_MAXLEN) - if not chunk: - break - strings["%s.%d" % (name, counter)] = chunk - - def toString(self, inObject): - """ - Convert to send on the wire, with compression. - """ - return zlib.compress(inObject, 9) - - def fromString(self, inString): - """ - Convert (decompress) from the wire to Python. - """ - return zlib.decompress(inString) - - -class MsgPortal2Server(amp.Command): - """ - Message Portal -> Server - - """ - key = "MsgPortal2Server" - arguments = [('packed_data', Compressed())] - errors = {Exception: 'EXCEPTION'} - response = [] - - -class MsgServer2Portal(amp.Command): - """ - Message Server -> Portal - - """ - key = "MsgServer2Portal" - arguments = [('packed_data', Compressed())] - errors = {Exception: 'EXCEPTION'} - response = [] - - -class AdminPortal2Server(amp.Command): - """ - Administration Portal -> Server - - Sent when the portal needs to perform admin operations on the - server, such as when a new session connects or resyncs - - """ - key = "AdminPortal2Server" - arguments = [('packed_data', Compressed())] - errors = {Exception: 'EXCEPTION'} - response = [] - - -class AdminServer2Portal(amp.Command): - """ - Administration Server -> Portal - - Sent when the server needs to perform admin operations on the - portal. - - """ - key = "AdminServer2Portal" - arguments = [('packed_data', Compressed())] - errors = {Exception: 'EXCEPTION'} - response = [] - - -class FunctionCall(amp.Command): - """ - Bidirectional Server <-> Portal - - Sent when either process needs to call an arbitrary function in - the other. This does not use the batch-send functionality. - - """ - key = "FunctionCall" - arguments = [('module', amp.String()), - ('function', amp.String()), - ('args', amp.String()), - ('kwargs', amp.String())] - errors = {Exception: 'EXCEPTION'} - response = [('result', amp.String())] - - -# Helper functions for pickling. - -def dumps(data): - return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL)) - - -def loads(data): - return pickle.loads(to_str(data)) - - -# ------------------------------------------------------------- -# Core AMP protocol for communication Server <-> Portal -# ------------------------------------------------------------- - -class AMPProtocol(amp.AMP): - """ - This is the protocol that the MUD server and the proxy server - communicate to each other with. AMP is a bi-directional protocol, - so both the proxy and the MUD use the same commands and protocol. - - AMP specifies responder methods here and connect them to - amp.Command subclasses that specify the datatypes of the - input/output of these methods. - - """ - - # helper methods - - def __init__(self, *args, **kwargs): - """ - Initialize protocol with some things that need to be in place - already before connecting both on portal and server. - - """ - self.send_batch_counter = 0 - self.send_reset_time = time.time() - self.send_mode = True - self.send_task = None - - def connectionMade(self): - """ - This is called when an AMP connection is (re-)established - between server and portal. AMP calls it on both sides, so we - need to make sure to only trigger resync from the portal side. - - """ - # this makes for a factor x10 faster sends across the wire - self.transport.setTcpNoDelay(True) - - if hasattr(self.factory, "portal"): - # only the portal has the 'portal' property, so we know we are - # on the portal side and can initialize the connection. - sessdata = self.factory.portal.sessions.get_all_sync_data() - self.send_AdminPortal2Server(DUMMYSESSION, - PSYNC, - sessiondata=sessdata) - self.factory.portal.sessions.at_server_connection() - if hasattr(self.factory, "server_restart_mode"): - del self.factory.server_restart_mode - - def connectionLost(self, reason): - """ - We swallow connection errors here. The reason is that during a - normal reload/shutdown there will almost always be cases where - either the portal or server shuts down before a message has - returned its (empty) return, triggering a connectionLost error - that is irrelevant. If a true connection error happens, the - portal will continuously try to reconnect, showing the problem - that way. - """ - pass - - # Error handling - - def errback(self, e, info): - """ - Error callback. - Handles errors to avoid dropping connections on server tracebacks. - - Args: - e (Failure): Deferred error instance. - info (str): Error string. - - """ - e.trap(Exception) - logger.log_err("AMP Error for %(info)s: %(e)s" % {'info': info, - 'e': e.getErrorMessage()}) - - def send_data(self, command, sessid, **kwargs): - """ - Send data across the wire. - - Args: - command (AMP Command): A protocol send command. - sessid (int): A unique Session id. - - Returns: - deferred (deferred or None): A deferred with an errback. - - Notes: - Data will be sent across the wire pickled as a tuple - (sessid, kwargs). - - """ - return self.callRemote(command, - packed_data=dumps((sessid, kwargs)) - ).addErrback(self.errback, command.key) - - # Message definition + helper methods to call/create each message type - - # Portal -> Server Msg - - @MsgPortal2Server.responder - def server_receive_msgportal2server(self, packed_data): - """ - Receives message arriving to server. This method is executed - on the Server. - - Args: - packed_data (str): Data to receive (a pickled tuple (sessid,kwargs)) - - """ - sessid, kwargs = loads(packed_data) - session = self.factory.server.sessions.get(sessid, None) - if session: - self.factory.server.sessions.data_in(session, **kwargs) - return {} - - def send_MsgPortal2Server(self, session, **kwargs): - """ - Access method called by the Portal and executed on the Portal. - - Args: - session (session): Session - kwargs (any, optional): Optional data. - - Returns: - deferred (Deferred): Asynchronous return. - - """ - return self.send_data(MsgPortal2Server, session.sessid, **kwargs) - - # Server -> Portal message - - @MsgServer2Portal.responder - def portal_receive_server2portal(self, packed_data): - """ - Receives message arriving to Portal from Server. - This method is executed on the Portal. - - Args: - packed_data (str): Pickled data (sessid, kwargs) coming over the wire. - """ - sessid, kwargs = loads(packed_data) - session = self.factory.portal.sessions.get(sessid, None) - if session: - self.factory.portal.sessions.data_out(session, **kwargs) - return {} - - def send_MsgServer2Portal(self, session, **kwargs): - """ - Access method - executed on the Server for sending data - to Portal. - - Args: - session (Session): Unique Session. - kwargs (any, optiona): Extra data. - - """ - return self.send_data(MsgServer2Portal, session.sessid, **kwargs) - - # Server administration from the Portal side - @AdminPortal2Server.responder - def server_receive_adminportal2server(self, packed_data): - """ - Receives admin data from the Portal (allows the portal to - perform admin operations on the server). This is executed on - the Server. - - Args: - packed_data (str): Incoming, pickled data. - - """ - sessid, kwargs = loads(packed_data) - operation = kwargs.pop("operation", "") - server_sessionhandler = self.factory.server.sessions - - if operation == PCONN: # portal_session_connect - # create a new session and sync it - server_sessionhandler.portal_connect(kwargs.get("sessiondata")) - - elif operation == PCONNSYNC: # portal_session_sync - server_sessionhandler.portal_session_sync(kwargs.get("sessiondata")) - - elif operation == PDISCONN: # portal_session_disconnect - # session closed from portal sid - session = server_sessionhandler.get(sessid) - if session: - server_sessionhandler.portal_disconnect(session) - - elif operation == PDISCONNALL: # portal_disconnect_all - # portal orders all sessions to close - server_sessionhandler.portal_disconnect_all() - - elif operation == PSYNC: # portal_session_sync - # force a resync of sessions when portal reconnects to - # server (e.g. after a server reboot) the data kwarg - # contains a dict {sessid: {arg1:val1,...}} - # representing the attributes to sync for each - # session. - server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) - else: - raise Exception("operation %(op)s not recognized." % {'op': operation}) - return {} - - def send_AdminPortal2Server(self, session, operation="", **kwargs): - """ - Send Admin instructions from the Portal to the Server. - Executed - on the Portal. - - Args: - session (Session): Session. - operation (char, optional): Identifier for the server operation, as defined by the - global variables in `evennia/server/amp.py`. - data (str or dict, optional): Data used in the administrative operation. - - """ - return self.send_data(AdminPortal2Server, session.sessid, operation=operation, **kwargs) - - # Portal administration from the Server side - - @AdminServer2Portal.responder - def portal_receive_adminserver2portal(self, packed_data): - """ - - Receives and handles admin operations sent to the Portal - This is executed on the Portal. - - Args: - packed_data (str): Data received, a pickled tuple (sessid, kwargs). - - """ - sessid, kwargs = loads(packed_data) - operation = kwargs.pop("operation") - portal_sessionhandler = self.factory.portal.sessions - - if operation == SLOGIN: # server_session_login - # a session has authenticated; sync it. - session = portal_sessionhandler.get(sessid) - if session: - portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata")) - - elif operation == SDISCONN: # server_session_disconnect - # the server is ordering to disconnect the session - session = portal_sessionhandler.get(sessid) - if session: - portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason")) - - elif operation == SDISCONNALL: # server_session_disconnect_all - # server orders all sessions to disconnect - portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) - - elif operation == SSHUTD: # server_shutdown - # the server orders the portal to shut down - self.factory.portal.shutdown(restart=False) - - elif operation == SSYNC: # server_session_sync - # server wants to save session data to the portal, - # maybe because it's about to shut down. - portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"), - kwargs.get("clean", True)) - # set a flag in case we are about to shut down soon - self.factory.server_restart_mode = True - - elif operation == SCONN: # server_force_connection (for irc/etc) - portal_sessionhandler.server_connect(**kwargs) - - else: - raise Exception("operation %(op)s not recognized." % {'op': operation}) - return {} - - def send_AdminServer2Portal(self, session, operation="", **kwargs): - """ - Administrative access method called by the Server to send an - instruction to the Portal. - - Args: - session (Session): Session. - operation (char, optional): Identifier for the server - operation, as defined by the global variables in - `evennia/server/amp.py`. - data (str or dict, optional): Data going into the adminstrative. - - """ - return self.send_data(AdminServer2Portal, session.sessid, operation=operation, **kwargs) - - # Extra functions - - @FunctionCall.responder - def receive_functioncall(self, module, function, func_args, func_kwargs): - """ - This allows Portal- and Server-process to call an arbitrary - function in the other process. It is intended for use by - plugin modules. - - Args: - module (str or module): The module containing the - `function` to call. - function (str): The name of the function to call in - `module`. - func_args (str): Pickled args tuple for use in `function` call. - func_kwargs (str): Pickled kwargs dict for use in `function` call. - - """ - args = loads(func_args) - kwargs = loads(func_kwargs) - - # call the function (don't catch tracebacks here) - result = variable_from_module(module, function)(*args, **kwargs) - - if isinstance(result, Deferred): - # if result is a deferred, attach handler to properly - # wrap the return value - result.addCallback(lambda r: {"result": dumps(r)}) - return result - else: - return {'result': dumps(result)} - - def send_FunctionCall(self, modulepath, functionname, *args, **kwargs): - """ - Access method called by either process. This will call an arbitrary - function on the other process (On Portal if calling from Server and - vice versa). - - Inputs: - modulepath (str) - python path to module holding function to call - functionname (str) - name of function in given module - *args, **kwargs will be used as arguments/keyword args for the - remote function call - Returns: - A deferred that fires with the return value of the remote - function call - - """ - return self.callRemote(FunctionCall, - module=modulepath, - function=functionname, - args=dumps(args), - kwargs=dumps(kwargs)).addCallback( - lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall") diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py new file mode 100644 index 0000000000..8b9f9d4e8e --- /dev/null +++ b/evennia/server/amp_client.py @@ -0,0 +1,239 @@ +""" +The Evennia Server service acts as an AMP-client when talking to the +Portal. This module sets up the Client-side communication. + +""" + +import os +from evennia.server.portal import amp +from twisted.internet import protocol +from evennia.utils import logger + + +class AMPClientFactory(protocol.ReconnectingClientFactory): + """ + This factory creates an instance of an AMP client connection. This handles communication from + the be the Evennia 'Server' service to the 'Portal'. The client will try to auto-reconnect on a + connection error. + + """ + # Initial reconnect delay in seconds. + initialDelay = 1 + factor = 1.5 + maxDelay = 1 + noisy = False + + def __init__(self, server): + """ + Initializes the client factory. + + Args: + server (server): server instance. + + """ + self.server = server + self.protocol = AMPServerClientProtocol + self.maxDelay = 10 + # not really used unless connecting to multiple servers, but + # avoids having to check for its existence on the protocol + self.broadcasts = [] + + def startedConnecting(self, connector): + """ + Called when starting to try to connect to the MUD server. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + + """ + pass + + def buildProtocol(self, addr): + """ + Creates an AMPProtocol instance when connecting to the server. + + Args: + addr (str): Connection address. Not used. + + """ + self.resetDelay() + self.server.amp_protocol = AMPServerClientProtocol() + self.server.amp_protocol.factory = self + return self.server.amp_protocol + + def clientConnectionLost(self, connector, reason): + """ + Called when the AMP connection to the MUD server is lost. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + reason (str): Eventual text describing why connection was lost. + + """ + logger.log_info("Server disconnected from the portal.") + protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionFailed(self, connector, reason): + """ + Called when an AMP connection attempt to the MUD server fails. + + Args: + connector (Connector): Twisted Connector instance representing + this connection. + reason (str): Eventual text describing why connection failed. + + """ + logger.log_msg("Attempting to reconnect to Portal ...") + protocol.ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) + + +class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): + """ + This protocol describes the Server service (acting as an AMP-client)'s communication with the + Portal (which acts as the AMP-server) + + """ + # sending AMP data + + def connectionMade(self): + """ + Called when a new connection is established. + + """ + info_dict = self.factory.server.get_info_dict() + super(AMPServerClientProtocol, self).connectionMade() + # first thing we do is to request the Portal to sync all sessions + # back with the Server side. We also need the startup mode (reload, reset, shutdown) + self.send_AdminServer2Portal( + amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict) + + def data_to_portal(self, command, sessid, **kwargs): + """ + Send data across the wire to the Portal + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + kwargs (any): Any data to pickle into the command. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + return self.callRemote(command, packed_data=amp.dumps((sessid, kwargs))).addErrback( + self.errback, command.key) + + def send_MsgServer2Portal(self, session, **kwargs): + """ + Access method - executed on the Server for sending data + to Portal. + + Args: + session (Session): Unique Session. + kwargs (any, optiona): Extra data. + + """ + return self.data_to_portal(amp.MsgServer2Portal, session.sessid, **kwargs) + + def send_AdminServer2Portal(self, session, operation="", **kwargs): + """ + Administrative access method called by the Server to send an + instruction to the Portal. + + Args: + session (Session): Session. + operation (char, optional): Identifier for the server + operation, as defined by the global variables in + `evennia/server/amp.py`. + kwargs (dict, optional): Data going into the adminstrative. + + """ + return self.data_to_portal(amp.AdminServer2Portal, session.sessid, + operation=operation, **kwargs) + + # receiving AMP data + + @amp.MsgStatus.responder + def server_receive_status(self, question): + return {"status": "OK"} + + @amp.MsgPortal2Server.responder + @amp.catch_traceback + def server_receive_msgportal2server(self, packed_data): + """ + Receives message arriving to server. This method is executed + on the Server. + + Args: + packed_data (str): Data to receive (a pickled tuple (sessid,kwargs)) + + """ + sessid, kwargs = self.data_in(packed_data) + session = self.factory.server.sessions.get(sessid, None) + if session: + self.factory.server.sessions.data_in(session, **kwargs) + return {} + + @amp.AdminPortal2Server.responder + @amp.catch_traceback + def server_receive_adminportal2server(self, packed_data): + """ + Receives admin data from the Portal (allows the portal to + perform admin operations on the server). This is executed on + the Server. + + Args: + packed_data (str): Incoming, pickled data. + + """ + sessid, kwargs = self.data_in(packed_data) + operation = kwargs.pop("operation", "") + server_sessionhandler = self.factory.server.sessions + + if operation == amp.PCONN: # portal_session_connect + # create a new session and sync it + server_sessionhandler.portal_connect(kwargs.get("sessiondata")) + + elif operation == amp.PCONNSYNC: # portal_session_sync + server_sessionhandler.portal_session_sync(kwargs.get("sessiondata")) + + elif operation == amp.PDISCONN: # portal_session_disconnect + # session closed from portal sid + session = server_sessionhandler.get(sessid) + if session: + server_sessionhandler.portal_disconnect(session) + + elif operation == amp.PDISCONNALL: # portal_disconnect_all + # portal orders all sessions to close + server_sessionhandler.portal_disconnect_all() + + elif operation == amp.PSYNC: # portal_session_sync + # force a resync of sessions from the portal side. This happens on + # first server-connect. + server_restart_mode = kwargs.get("server_restart_mode", "shutdown") + self.factory.server.run_init_hooks(server_restart_mode) + server_sessionhandler.portal_sessions_sync(kwargs.get("sessiondata")) + + elif operation == amp.SRELOAD: # server reload + # shut down in reload mode + server_sessionhandler.all_sessions_portal_sync() + server_sessionhandler.server.shutdown(mode='reload') + + elif operation == amp.SRESET: + # shut down in reset mode + server_sessionhandler.all_sessions_portal_sync() + server_sessionhandler.server.shutdown(mode='reset') + + elif operation == amp.SSHUTD: # server shutdown + # shutdown in stop mode + server_sessionhandler.server.shutdown(mode='shutdown') + + else: + raise Exception("operation %(op)s not recognized." % {'op': operation}) + return {} diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 9ccf664410..c82b922ca0 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1,12 +1,13 @@ -#!/usr/bin/env python +#!/usr/bin/python """ -EVENNIA SERVER LAUNCHER SCRIPT +Evennia launcher program This is the start point for running Evennia. -Sets the appropriate environmental variables and launches the server -and portal through the evennia_runner. Run without arguments to get a -menu. Run the script with the -h flag to see usage information. +Sets the appropriate environmental variables for managing an Evennia game. It will start and connect +to the Portal, through which the Server is also controlled. This pprogram + +Run the script with the -h flag to see usage information. """ from __future__ import print_function @@ -19,7 +20,16 @@ import shutil import importlib from distutils.version import LooseVersion from argparse import ArgumentParser +import argparse from subprocess import Popen, check_output, call, CalledProcessError, STDOUT + +try: + import cPickle as pickle +except ImportError: + import pickle + +from twisted.protocols import amp +from twisted.internet import reactor, endpoints import django # Signal processing @@ -29,7 +39,7 @@ CTRL_C_EVENT = 0 # Windows SIGINT-like signal # Set up the main python paths to Evennia EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import evennia +import evennia # noqa EVENNIA_LIB = os.path.join(os.path.dirname(os.path.abspath(evennia.__file__))) EVENNIA_SERVER = os.path.join(EVENNIA_LIB, "server") EVENNIA_RUNNER = os.path.join(EVENNIA_SERVER, "evennia_runner.py") @@ -49,18 +59,40 @@ CURRENT_DIR = os.getcwd() GAMEDIR = CURRENT_DIR # Operational setup + SERVER_LOGFILE = None PORTAL_LOGFILE = None HTTP_LOGFILE = None + SERVER_PIDFILE = None PORTAL_PIDFILE = None -SERVER_RESTART = None -PORTAL_RESTART = None + SERVER_PY_FILE = None PORTAL_PY_FILE = None + +SPROFILER_LOGFILE = None +PPROFILER_LOGFILE = None + TEST_MODE = False ENFORCED_SETTING = False +REACTOR_RUN = False +NO_REACTOR_STOP = False + +# communication constants + +AMP_PORT = None +AMP_HOST = None +AMP_INTERFACE = None +AMP_CONNECTION = None + +SRELOAD = chr(14) # server reloading (have portal start a new server) +SSTART = chr(15) # server start +PSHUTD = chr(16) # portal (+server) shutdown +SSHUTD = chr(17) # server-only shutdown +PSTATUS = chr(18) # ping server or portal status +SRESET = chr(19) # shutdown server in reset mode + # requirements PYTHON_MIN = '2.7' TWISTED_MIN = '16.0.0' @@ -69,11 +101,11 @@ DJANGO_REC = '1.11' sys.path[1] = EVENNIA_ROOT -#------------------------------------------------------------ +# ------------------------------------------------------------ # # Messages # -#------------------------------------------------------------ +# ------------------------------------------------------------ CREATED_NEW_GAMEDIR = \ """ @@ -107,6 +139,11 @@ ERROR_INPUT = \ raised an error: '{traceback}'. """ +ERROR_NO_ALT_GAMEDIR = \ + """ + The path '{gamedir}' could not be found. +""" + ERROR_NO_GAMEDIR = \ """ ERROR: No Evennia settings file was found. Evennia looks for the @@ -230,12 +267,8 @@ INFO_WINDOWS_BATFILE = \ """ CMDLINE_HELP = \ - """ - Starts or operates the Evennia MU* server. Allows for - initializing a new game directory and manages the game's database. - Most standard django-admin arguments and options can also be - passed. - """ + """Starts, initializes, manages and operates the Evennia MU* server. +Most standard django management commands are also accepted.""" VERSION_INFO = \ @@ -255,52 +288,60 @@ ABOUT_INFO = \ Web: http://www.evennia.com Irc: #evennia on FreeNode Forum: http://www.evennia.com/discussions - Maintainer (2010-): Griatch (griatch AT gmail DOT com) Maintainer (2006-10): Greg Taylor + Maintainer (2010-): Griatch (griatch AT gmail DOT com) Use -h for command line options. """ HELP_ENTRY = \ """ - Enter 'evennia -h' for command-line options. + Evennia has two processes, the 'Server' and the 'Portal'. + External users connect to the Portal while the Server runs the + game/database. Restarting the Server will refresh code but not + disconnect users. - Use option (1) in a production environment. During development (2) is - usually enough, portal debugging is usually only useful if you are - adding new protocols or are debugging Evennia itself. + To start a new game, use 'evennia --init mygame'. + For more ways to operate and manage Evennia, see 'evennia -h'. - Reload with (5) to update the server with your changes without - disconnecting any accounts. + If you want to add unit tests to your game, see + https://github.com/evennia/evennia/wiki/Unit-Testing - Note: Reload and stop are sometimes poorly supported in Windows. If you - have issues, log into the game to stop or restart the server instead. + Evennia's manual is found here: + https://github.com/evennia/evennia/wiki """ MENU = \ """ +----Evennia Launcher-------------------------------------------+ - | | - +--- Starting --------------------------------------------------+ - | | - | 1) (normal): All output to logfiles | - | 2) (server devel): Server logs to terminal (-i option) | - | 3) (portal devel): Portal logs to terminal | - | 4) (full devel): Both Server and Portal logs to terminal | - | | - +--- Restarting ------------------------------------------------+ - | | - | 5) Reload the Server | - | 6) Reload the Portal (only works with portal/full debug) | - | | - +--- Stopping --------------------------------------------------+ - | | - | 7) Stopping both Portal and Server | - | 8) Stopping only Server | - | 9) Stopping only Portal | - | | - +---------------------------------------------------------------+ - | h) Help i) About info q) Abort | + {gameinfo} + +--- Common operations -----------------------------------------+ + | 1) Start (also restart stopped Server) | + | 2) Reload (stop/start Server in 'reload' mode) | + | 3) Stop (shutdown Portal and Server) | + | 4) Reboot (shutdown then restart) | + +--- Other operations ------------------------------------------+ + | 5) Reset (stop/start Server in 'shutdown' mode) | + | 6) Stop Server only | + | 7) Kill Server only (send kill signal to process) | + | 8) Kill Portal + Server | + +--- Information -----------------------------------------------+ + | 9) Tail log files (quickly see errors) | + | 10) Status | + | 11) Port info | + +--- Testing ---------------------------------------------------+ + | 12) Test gamedir (run gamedir test suite, if any) | + | 13) Test Evennia (run Evennia test suite) | +---------------------------------------------------------------+ + | h) Help i) About info q) Abort | + +---------------------------------------------------------------+""" + +ERROR_AMP_UNCONFIGURED = \ + """ + Can't find server info for connecting. Either run this command from + the game dir (it will then use the game's settings file) or specify + the path to your game's settings file manually with the --settings + option. """ ERROR_LOGDIR_MISSING = \ @@ -319,7 +360,6 @@ ERROR_LOGDIR_MISSING = \ you used git to clone a pre-created game directory - since log files are in .gitignore they will not be cloned, which leads to the log directory also not being created.) - """ ERROR_PYTHON_VERSION = \ @@ -391,11 +431,679 @@ NOTE_TEST_CUSTOM = \ on the game dir.) """ -#------------------------------------------------------------ +PROCESS_ERROR = \ + """ + {component} process error: {traceback}. + """ + +PORTAL_INFO = \ + """{servername} Portal {version} + external ports: + {telnet} + {telnet_ssl} + {ssh} + {webserver_proxy} + {webclient} + internal_ports (to Server): + {webserver_internal} + {amp} +""" + + +SERVER_INFO = \ + """{servername} Server {version} + internal ports (to Portal): + {webserver} + {amp} + {irc_rss} + {info} + {errors}""" + + +ARG_OPTIONS = \ + """Actions on installed server. One of: + start - launch server+portal if not running + reload - restart server in 'reload' mode + stop - shutdown server+portal + reboot - shutdown server+portal, then start again + reset - restart server in 'shutdown' mode + sstart - start only server (requires portal) + kill - send kill signal to portal+server (force) + skill = send kill signal only to server + status - show server and portal run state + info - show server and portal port info + menu - show a menu of options +Others, like migrate, test and shell is passed on to Django.""" + +# ------------------------------------------------------------ # -# Functions +# Private helper functions # -#------------------------------------------------------------ +# ------------------------------------------------------------ + + +def _is_windows(): + return os.name == 'nt' + + +def _file_names_compact(filepath1, filepath2): + "Compact the output of filenames with same base dir" + dirname1 = os.path.dirname(filepath1) + dirname2 = os.path.dirname(filepath2) + if dirname1 == dirname2: + name2 = os.path.basename(filepath2) + return "{} and {}".format(filepath1, name2) + else: + return "{} and {}". format(filepath1, filepath2) + + +def _print_info(portal_info_dict, server_info_dict): + """ + Format info dicts from the Portal/Server for display + + """ + ind = " " * 8 + + def _prepare_dict(dct): + out = {} + for key, value in dct.iteritems(): + if isinstance(value, list): + value = "\n{}".format(ind).join(value) + out[key] = value + return out + + def _strip_empty_lines(string): + return "\n".join(line for line in string.split("\n") if line.strip()) + + pstr, sstr = "", "" + if portal_info_dict: + pdict = _prepare_dict(portal_info_dict) + pstr = _strip_empty_lines(PORTAL_INFO.format(**pdict)) + + if server_info_dict: + sdict = _prepare_dict(server_info_dict) + sstr = _strip_empty_lines(SERVER_INFO.format(**sdict)) + + info = pstr + ("\n\n" + sstr if sstr else "") + maxwidth = max(len(line) for line in info.split("\n")) + top_border = "-" * (maxwidth - 11) + " Evennia " + "---" + border = "-" * (maxwidth + 1) + print(top_border + "\n" + info + '\n' + border) + + +def _parse_status(response): + "Unpack the status information" + return pickle.loads(response['status']) + + +def _get_twistd_cmdline(pprofiler, sprofiler): + """ + Compile the command line for starting a Twisted application using the 'twistd' executable. + + """ + portal_cmd = [TWISTED_BINARY, + "--python={}".format(PORTAL_PY_FILE)] + server_cmd = [TWISTED_BINARY, + "--python={}".format(SERVER_PY_FILE)] + + if os.name != 'nt': + # PID files only for UNIX + portal_cmd.append("--pidfile={}".format(PORTAL_PIDFILE)) + server_cmd.append("--pidfile={}".format(SERVER_PIDFILE)) + + if pprofiler: + portal_cmd.extend(["--savestats", + "--profiler=cprofile", + "--profile={}".format(PPROFILER_LOGFILE)]) + if sprofiler: + server_cmd.extend(["--savestats", + "--profiler=cprofile", + "--profile={}".format(SPROFILER_LOGFILE)]) + + return portal_cmd, server_cmd + + +def _reactor_stop(): + if not NO_REACTOR_STOP: + reactor.stop() + + +# ------------------------------------------------------------ +# +# Protocol Evennia launcher - Portal/Server communication +# +# ------------------------------------------------------------ + + +class MsgStatus(amp.Command): + """ + Ping between AMP services + + """ + key = "MsgStatus" + arguments = [('status', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('status', amp.String())] + + +class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + key = "MsgLauncher2Portal" + arguments = [('operation', amp.String()), + ('arguments', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class AMPLauncherProtocol(amp.AMP): + """ + Defines callbacks to the launcher + + """ + def __init__(self): + self.on_status = [] + + def wait_for_status(self, callback): + """ + Register a waiter for a status return. + + """ + self.on_status.append(callback) + + @MsgStatus.responder + def receive_status_from_portal(self, status): + """ + Get a status signal from portal - fire next queued + callback + + """ + try: + callback = self.on_status.pop() + except IndexError: + pass + else: + status = pickle.loads(status) + callback(status) + return {"status": ""} + + +def send_instruction(operation, arguments, callback=None, errback=None): + """ + Send instruction and handle the response. + + """ + global AMP_CONNECTION, REACTOR_RUN + + if None in (AMP_HOST, AMP_PORT, AMP_INTERFACE): + print(ERROR_AMP_UNCONFIGURED) + sys.exit() + + def _callback(result): + if callback: + callback(result) + + def _errback(fail): + if errback: + errback(fail) + + def _on_connect(prot): + """ + This fires with the protocol when connection is established. We + immediately send off the instruction + + """ + global AMP_CONNECTION + AMP_CONNECTION = prot + _send() + + def _on_connect_fail(fail): + "This is called if portal is not reachable." + errback(fail) + + def _send(): + if operation == PSTATUS: + return AMP_CONNECTION.callRemote(MsgStatus, status="").addCallbacks(_callback, _errback) + else: + return AMP_CONNECTION.callRemote( + MsgLauncher2Portal, + operation=operation, + arguments=pickle.dumps(arguments, pickle.HIGHEST_PROTOCOL)).addCallbacks( + _callback, _errback) + + if AMP_CONNECTION: + # already connected - send right away + _send() + else: + # we must connect first, send once connected + point = endpoints.TCP4ClientEndpoint(reactor, AMP_HOST, AMP_PORT) + deferred = endpoints.connectProtocol(point, AMPLauncherProtocol()) + deferred.addCallbacks(_on_connect, _on_connect_fail) + REACTOR_RUN = True + + +def query_status(callback=None): + """ + Send status ping to portal + + """ + wmap = {True: "RUNNING", + False: "NOT RUNNING"} + + def _callback(response): + if callback: + callback(response) + else: + pstatus, sstatus, ppid, spid, pinfo, sinfo = _parse_status(response) + print("Portal: {}{}\nServer: {}{}".format( + wmap[pstatus], " (pid {})".format( + get_pid(PORTAL_PIDFILE, ppid)) if pstatus else "", + wmap[sstatus], " (pid {})".format( + get_pid(SERVER_PIDFILE, spid)) if sstatus else "")) + _reactor_stop() + + def _errback(fail): + pstatus, sstatus = False, False + print("Portal: {}\nServer: {}".format(wmap[pstatus], wmap[sstatus])) + _reactor_stop() + + send_instruction(PSTATUS, None, _callback, _errback) + + +def wait_for_status_reply(callback): + """ + Wait for an explicit STATUS signal to be sent back from Evennia. + """ + if AMP_CONNECTION: + AMP_CONNECTION.wait_for_status(callback) + else: + print("No Evennia connection established.") + + +def wait_for_status(portal_running=True, server_running=True, callback=None, errback=None, + rate=0.5, retries=20): + """ + Repeat the status ping until the desired state combination is achieved. + + Args: + portal_running (bool or None): Desired portal run-state. If None, any state + is accepted. + server_running (bool or None): Desired server run-state. If None, any state + is accepted. The portal must be running. + callback (callable): Will be called with portal_state, server_state when + condition is fulfilled. + errback (callable): Will be called with portal_state, server_state if the + request is timed out. + rate (float): How often to retry. + retries (int): How many times to retry before timing out and calling `errback`. + """ + def _callback(response): + prun, srun, _, _, _, _ = _parse_status(response) + if ((portal_running is None or prun == portal_running) and + (server_running is None or srun == server_running)): + # the correct state was achieved + if callback: + callback(prun, srun) + else: + _reactor_stop() + else: + if retries <= 0: + if errback: + errback(prun, srun) + else: + print("Connection to Evennia timed out. Try again.") + _reactor_stop() + else: + reactor.callLater(rate, wait_for_status, + portal_running, server_running, + callback, errback, rate, retries - 1) + + def _errback(fail): + """ + Portal not running + """ + if not portal_running: + # this is what we want + if callback: + callback(portal_running, server_running) + else: + _reactor_stop() + else: + if retries <= 0: + if errback: + errback(portal_running, server_running) + else: + print("Connection to Evennia timed out. Try again.") + _reactor_stop() + else: + reactor.callLater(rate, wait_for_status, + portal_running, server_running, + callback, errback, rate, retries - 1) + + return send_instruction(PSTATUS, None, _callback, _errback) + + +# ------------------------------------------------------------ +# +# Operational functions +# +# ------------------------------------------------------------ + +def start_evennia(pprofiler=False, sprofiler=False): + """ + This will start Evennia anew by launching the Evennia Portal (which in turn + will start the Server) + + """ + portal_cmd, server_cmd = _get_twistd_cmdline(pprofiler, sprofiler) + + def _fail(fail): + print(fail) + _reactor_stop() + + def _server_started(response): + print("... Server started.\nEvennia running.") + if response: + _, _, _, _, pinfo, sinfo = response + _print_info(pinfo, sinfo) + _reactor_stop() + + def _portal_started(*args): + print("... Portal started.\nServer starting {} ...".format( + "(under cProfile)" if sprofiler else "")) + wait_for_status_reply(_server_started) + send_instruction(SSTART, server_cmd) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + print("Portal is already running as process {pid}. Not restarted.".format(pid=ppid)) + if srun: + print("Server is already running as process {pid}. Not restarted.".format(pid=spid)) + _reactor_stop() + else: + print("Server starting {}...".format("(under cProfile)" if sprofiler else "")) + send_instruction(SSTART, server_cmd, _server_started, _fail) + + def _portal_not_running(fail): + print("Portal starting {}...".format("(under cProfile)" if pprofiler else "")) + try: + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + Popen(portal_cmd, env=getenv(), bufsize=-1, + creationflags=create_no_window) + else: + Popen(portal_cmd, env=getenv(), bufsize=-1) + except Exception as e: + print(PROCESS_ERROR.format(component="Portal", traceback=e)) + _reactor_stop() + wait_for_status(True, None, _portal_started) + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def reload_evennia(sprofiler=False, reset=False): + """ + This will instruct the Portal to reboot the Server component. We + do this manually by telling the server to shutdown (in reload mode) + and wait for the portal to report back, at which point we start the + server again. This way we control the process exactly. + + """ + _, server_cmd = _get_twistd_cmdline(False, sprofiler) + + def _server_restarted(*args): + print("... Server re-started.") + _reactor_stop() + + def _server_reloaded(status): + print("... Server {}.".format("reset" if reset else "reloaded")) + _reactor_stop() + + def _server_stopped(status): + wait_for_status_reply(_server_reloaded) + send_instruction(SSTART, server_cmd) + + def _portal_running(response): + _, srun, _, _, _, _ = _parse_status(response) + if srun: + print("Server {}...".format("resetting" if reset else "reloading")) + wait_for_status_reply(_server_stopped) + send_instruction(SRESET if reset else SRELOAD, {}) + else: + print("Server down. Re-starting ...") + wait_for_status_reply(_server_restarted) + send_instruction(SSTART, server_cmd) + + def _portal_not_running(fail): + print("Evennia not running. Starting up ...") + start_evennia() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def stop_evennia(): + """ + This instructs the Portal to stop the Server and then itself. + + """ + def _portal_stopped(*args): + print("... Portal stopped.\nEvennia shut down.") + _reactor_stop() + + def _server_stopped(*args): + print("... Server stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + send_instruction(SSHUTD, {}) + wait_for_status_reply(_server_stopped) + else: + print("Server already stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_not_running(fail): + print("Evennia not running.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def reboot_evennia(pprofiler=False, sprofiler=False): + """ + This is essentially an evennia stop && evennia start except we make sure + the system has successfully shut down before starting it again. + + If evennia was not running, start it. + + """ + global AMP_CONNECTION + + def _portal_stopped(*args): + print("... Portal stopped. Evennia shut down. Rebooting ...") + global AMP_CONNECTION + AMP_CONNECTION = None + start_evennia(pprofiler, sprofiler) + + def _server_stopped(*args): + print("... Server stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_running(response): + prun, srun, ppid, spid, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + send_instruction(SSHUTD, {}) + wait_for_status_reply(_server_stopped) + else: + print("Server already stopped.\nStopping Portal ...") + send_instruction(PSHUTD, {}) + wait_for_status(False, None, _portal_stopped) + + def _portal_not_running(fail): + print("Evennia not running. Starting up ...") + start_evennia() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def stop_server_only(): + """ + Only stop the Server-component of Evennia (this is not useful except for debug) + + """ + def _server_stopped(*args): + print("... Server stopped.") + _reactor_stop() + + def _portal_running(response): + _, srun, _, _, _, _ = _parse_status(response) + if srun: + print("Server stopping ...") + wait_for_status_reply(_server_stopped) + send_instruction(SSHUTD, {}) + else: + print("Server is not running.") + _reactor_stop() + + def _portal_not_running(fail): + print("Evennia is not running.") + _reactor_stop() + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def query_info(): + """ + Display the info strings from the running Evennia + + """ + def _got_status(status): + _, _, _, _, pinfo, sinfo = _parse_status(status) + _print_info(pinfo, sinfo) + _reactor_stop() + + def _portal_running(response): + query_status(_got_status) + + def _portal_not_running(fail): + print("Evennia is not running.") + + send_instruction(PSTATUS, None, _portal_running, _portal_not_running) + + +def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate=1): + """ + Tail two logfiles interactively, combining their output to stdout + + When first starting, this will display the tail of the log files. After + that it will poll the log files repeatedly and display changes. + + Args: + filename1 (str): Path to first log file. + filename2 (str): Path to second log file. + start_lines1 (int): How many lines to show from existing first log. + start_lines2 (int): How many lines to show from existing second log. + rate (int, optional): How often to poll the log file. + + """ + global REACTOR_RUN + + def _file_changed(filename, prev_size): + "Get size of file in bytes, get diff compared with previous size" + new_size = os.path.getsize(filename) + return new_size != prev_size, new_size + + def _get_new_lines(filehandle, old_linecount): + "count lines, get the ones not counted before" + + def _block(filehandle, size=65536): + "File block generator for quick traversal" + while True: + dat = filehandle.read(size) + if not dat: + break + yield dat + + # count number of lines in file + new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) + + if new_linecount < old_linecount: + # this could happen if the file was manually deleted or edited + print("Log file has shrunk. Restart log reader.") + sys.exit() + + lines_to_get = max(0, new_linecount - old_linecount) + + if not lines_to_get: + return [], old_linecount + + lines_found = [] + buffer_size = 4098 + block_count = -1 + + while len(lines_found) < lines_to_get: + try: + # scan backwards in file, starting from the end + filehandle.seek(block_count * buffer_size, os.SEEK_END) + except IOError: + # file too small for current seek, include entire file + filehandle.seek(0) + lines_found = filehandle.readlines() + break + lines_found = filehandle.readlines() + block_count -= 1 + + # only actually return the new lines + return lines_found[-lines_to_get:], new_linecount + + def _tail_file(filename, file_size, line_count, max_lines=None): + """This will cycle repeatedly, printing new lines""" + + # poll for changes + has_changed, file_size = _file_changed(filename, file_size) + + if has_changed: + try: + with open(filename, 'r') as filehandle: + new_lines, line_count = _get_new_lines(filehandle, line_count) + except IOError: + # the log file might not exist yet. Wait a little, then try again ... + pass + else: + if max_lines == 0: + # don't show any lines from old file + new_lines = [] + elif max_lines: + # show some lines from first startup + new_lines = new_lines[-max_lines:] + + # print to stdout without line break (log has its own line feeds) + sys.stdout.write("".join(new_lines)) + sys.stdout.flush() + + # set up the next poll + reactor.callLater(rate, _tail_file, filename, file_size, line_count, max_lines=100) + + reactor.callLater(0, _tail_file, filename1, 0, 0, max_lines=start_lines1) + reactor.callLater(0, _tail_file, filename2, 0, 0, max_lines=start_lines2) + + REACTOR_RUN = True + + +# ------------------------------------------------------------ +# +# Environment setup +# +# ------------------------------------------------------------ def evennia_version(): @@ -477,13 +1185,15 @@ def check_main_evennia_dependencies(): def set_gamedir(path): """ Set GAMEDIR based on path, by figuring out where the setting file - is inside the directory tree. + is inside the directory tree. This allows for running the launcher + from elsewhere than the top of the gamedir folder. """ global GAMEDIR Ndepth = 10 settings_path = os.path.join("server", "conf", "settings.py") + os.chdir(GAMEDIR) for i in range(Ndepth): gpath = os.getcwd() if "server" in os.listdir(gpath): @@ -537,10 +1247,10 @@ def create_settings_file(init=True, secret_settings=False): if os.path.exists(settings_path): inp = input("%s already exists. Do you want to reset it? y/[N]> " % settings_path) if not inp.lower() == 'y': - print ("Aborted.") + print("Aborted.") return else: - print ("Reset the settings file.") + print("Reset the settings file.") default_settings_path = os.path.join(EVENNIA_TEMPLATE, "server", "conf", "settings.py") shutil.copy(default_settings_path, settings_path) @@ -657,28 +1367,29 @@ def getenv(): env (dict): Environment global dict. """ - sep = ";" if os.name == 'nt' else ":" + sep = ";" if _is_windows() else ":" env = os.environ.copy() env['PYTHONPATH'] = sep.join(sys.path) return env -def get_pid(pidfile): +def get_pid(pidfile, default=None): """ Get the PID (Process ID) by trying to access an PID file. Args: pidfile (str): The path of the pid file. + default (int, optional): What to return if file does not exist. Returns: - pid (str or None): The process id. + pid (str): The process id or `default`. """ if os.path.exists(pidfile): with open(pidfile, 'r') as f: pid = f.read() return pid - return None + return default def del_pid(pidfile): @@ -695,60 +1406,65 @@ def del_pid(pidfile): os.remove(pidfile) -def kill(pidfile, killsignal=SIG, succmsg="", errmsg="", - restart_file=SERVER_RESTART, restart=False): +def kill(pidfile, component='Server', callback=None, errback=None, killsignal=SIG): """ Send a kill signal to a process based on PID. A customized success/error message will be returned. If clean=True, the system - will attempt to manually remove the pid file. + will attempt to manually remove the pid file. On Windows, no arguments + are useful since Windows has no ability to direct signals except to all + children of a console. Args: - pidfile (str): The path of the pidfile to get the PID from. - killsignal (int, optional): Signal identifier for signal to send. - succmsg (str, optional): Message to log on success. - errmsg (str, optional): Message to log on failure. - restart_file (str, optional): Restart file location. - restart (bool, optional): Are we in restart mode or not. + pidfile (str): The path of the pidfile to get the PID from. This is ignored + on Windows. + component (str, optional): Usually one of 'Server' or 'Portal'. This is + ignored on Windows. + errback (callable, optional): Called if signal failed to send. This + is ignored on Windows. + callback (callable, optional): Called if kill signal was sent successfully. + This is ignored on Windows. + killsignal (int, optional): Signal identifier for signal to send. This is + Ignored on Windows. """ - pid = get_pid(pidfile) - if pid: - if os.name == 'nt': - os.remove(pidfile) - # set restart/norestart flag - if restart: - django.core.management.call_command( - 'collectstatic', interactive=False, verbosity=0) - with open(restart_file, 'w') as f: - f.write("reload") - else: - with open(restart_file, 'w') as f: - f.write("shutdown") + if _is_windows(): + # Windows signal sending is very limited. + from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler try: - if os.name == 'nt': - from win32api import GenerateConsoleCtrlEvent, SetConsoleCtrlHandler - try: - # Windows can only send a SIGINT-like signal to - # *every* process spawned off the same console, so we must - # avoid killing ourselves here. - SetConsoleCtrlHandler(None, True) - GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) - except KeyboardInterrupt: - # We must catch and ignore the interrupt sent. - pass - else: - # Linux can send the SIGINT signal directly - # to the specified PID. - os.kill(int(pid), killsignal) + # Windows can only send a SIGINT-like signal to + # *every* process spawned off the same console, so we must + # avoid killing ourselves here. + SetConsoleCtrlHandler(None, True) + GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) + except KeyboardInterrupt: + # We must catch and ignore the interrupt sent. + pass + print("Sent kill signal to all spawned processes") - except OSError: - print("Process %(pid)s cannot be stopped. " - "The PID file 'server/%(pidfile)s' seems stale. " - "Try removing it." % {'pid': pid, 'pidfile': pidfile}) - return - print("Evennia:", succmsg) - return - print("Evennia:", errmsg) + else: + # Linux/Unix/Mac can send kill signal directly to specific PIDs. + pid = get_pid(pidfile) + if pid: + if _is_windows(): + os.remove(pidfile) + try: + os.kill(int(pid), killsignal) + except OSError: + print("{component} ({pid}) cannot be stopped. " + "The PID file '{pidfile}' seems stale. " + "Try removing it manually.".format( + component=component, pid=pid, pidfile=pidfile)) + return + if callback: + callback() + else: + print("Sent kill signal to {component}.".format(component=component)) + return + if errback: + errback() + else: + print("Could not send kill signal - {component} does " + "not appear to be running.".format(component=component)) def show_version_info(about=False): @@ -804,7 +1520,7 @@ def error_check_python_modules(): _imp(settings.COMMAND_PARSER) _imp(settings.SEARCH_AT_RESULT) _imp(settings.CONNECTION_SCREEN_MODULE) - #imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False) + # imp(settings.AT_INITIAL_SETUP_HOOK_MODULE, split=False) for path in settings.LOCK_FUNC_MODULES: _imp(path, split=False) @@ -824,6 +1540,12 @@ def error_check_python_modules(): _imp(settings.BASE_SCRIPT_TYPECLASS) +# ------------------------------------------------------------ +# +# Options +# +# ------------------------------------------------------------ + def init_game_directory(path, check_db=True): """ Try to analyze the given path to find settings.py - this defines @@ -869,20 +1591,25 @@ def init_game_directory(path, check_db=True): check_database() # set up the Evennia executables and log file locations + global AMP_PORT, AMP_HOST, AMP_INTERFACE global SERVER_PY_FILE, PORTAL_PY_FILE global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE global SERVER_PIDFILE, PORTAL_PIDFILE - global SERVER_RESTART, PORTAL_RESTART + global SPROFILER_LOGFILE, PPROFILER_LOGFILE global EVENNIA_VERSION + AMP_PORT = settings.AMP_PORT + AMP_HOST = settings.AMP_HOST + AMP_INTERFACE = settings.AMP_INTERFACE + SERVER_PY_FILE = os.path.join(EVENNIA_LIB, "server", "server.py") - PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "portal", "portal", "portal.py") + PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, "server", "portal", "portal.py") SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") - SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart") - PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart") + SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") + PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") SERVER_LOGFILE = settings.SERVER_LOG_FILE PORTAL_LOGFILE = settings.PORTAL_LOG_FILE @@ -898,7 +1625,7 @@ def init_game_directory(path, check_db=True): print(ERROR_LOGDIR_MISSING.format(logfiles=errstr)) sys.exit() - if os.name == 'nt': + if _is_windows(): # We need to handle Windows twisted separately. We create a # batchfile in game/server, linking to the actual binary @@ -1007,8 +1734,11 @@ def run_menu(): """ while True: # menu loop + gamedir = "/{}".format(os.path.basename(GAMEDIR)) + leninfo = len(gamedir) + line = "|" + " " * (61 - leninfo) + gamedir + " " * 2 + "|" - print(MENU) + print(MENU.format(gameinfo=line)) inp = input(" option > ") # quitting and help @@ -1030,223 +1760,104 @@ def run_menu(): print("Not a valid option.") continue if inp == 1: - # start everything, log to log files - server_operation("start", "all", False, False) + start_evennia(False, False) elif inp == 2: - # start everything, server interactive start - server_operation("start", "all", True, False) + reload_evennia(False, False) elif inp == 3: - # start everything, portal interactive start - server_operation("start", "server", False, False) - server_operation("start", "portal", True, False) + stop_evennia() elif inp == 4: - # start both server and portal interactively - server_operation("start", "server", True, False) - server_operation("start", "portal", True, False) + reboot_evennia(False, False) elif inp == 5: - # reload the server - server_operation("reload", "server", None, None) + reload_evennia(False, True) elif inp == 6: - # reload the portal - server_operation("reload", "portal", None, None) + stop_server_only() elif inp == 7: - # stop server and portal - server_operation("stop", "all", None, None) + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, 'Server') elif inp == 8: - # stop server - server_operation("stop", "server", None, None) + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, 'Server') + kill(PORTAL_PIDFILE, 'Portal') elif inp == 9: - # stop portal - server_operation("stop", "portal", None, None) + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, 20, 20) + print(" Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE))) + elif inp == 10: + query_status() + elif inp == 11: + query_info() + elif inp == 12: + print("Running 'evennia --settings settings.py test .' ...") + Popen([sys.executable, __file__, '--settings', 'settings.py', 'test', '.'], + env=getenv()).wait() + elif inp == 13: + print("Running 'evennia test evennia' ...") + Popen([sys.executable, __file__, 'test', 'evennia'], env=getenv()).wait() else: print("Not a valid option.") continue return -def server_operation(mode, service, interactive, profiler, logserver=False, doexit=False): - """ - Handle argument options given on the command line. - - Args: - mode (str): Start/stop/restart and so on. - service (str): "server", "portal" or "all". - interactive (bool). Use interactive mode or daemon. - profiler (bool): Run the service under the profiler. - logserver (bool, optional): Log Server data to logfile - specified by settings.SERVER_LOG_FILE. - doexit (bool, optional): If True, immediately exit the runner after - starting the relevant processes. If the runner exits, Evennia - cannot be reloaded. This is meant to be used with an external - process manager like Linux' start-stop-daemon. - - """ - - cmdstr = [sys.executable, EVENNIA_RUNNER] - errmsg = "The %s does not seem to be running." - - if mode == 'start': - - # launch the error checker. Best to catch the errors already here. - error_check_python_modules() - - # starting one or many services - if service == 'server': - if profiler: - cmdstr.append('--pserver') - if interactive: - cmdstr.append('--iserver') - if logserver: - cmdstr.append('--logserver') - cmdstr.append('--noportal') - elif service == 'portal': - if profiler: - cmdstr.append('--pportal') - if interactive: - cmdstr.append('--iportal') - cmdstr.append('--noserver') - django.core.management.call_command( - 'collectstatic', verbosity=1, interactive=False) - else: - # all - # for convenience we don't start logging of - # portal, only of server with this command. - if profiler: - # this is the common case - cmdstr.append('--pserver') - if interactive: - cmdstr.append('--iserver') - if logserver: - cmdstr.append('--logserver') - django.core.management.call_command( - 'collectstatic', verbosity=1, interactive=False) - if doexit: - cmdstr.append('--doexit') - cmdstr.extend([ - GAMEDIR, TWISTED_BINARY, SERVER_LOGFILE, - PORTAL_LOGFILE, HTTP_LOGFILE]) - # start the server - process = Popen(cmdstr, env=getenv()) - - if interactive: - try: - process.wait() - except KeyboardInterrupt: - server_operation("stop", "portal", False, False) - return - finally: - print(NOTE_KEYBOARDINTERRUPT) - - elif mode == 'reload': - # restarting services - if os.name == 'nt': - print( - "Restarting from command line is not supported under Windows. " - "Use the in-game command (@reload by default) " - "or use 'evennia stop && evennia start' for a cold reboot.") - return - if service == 'server': - kill(SERVER_PIDFILE, SIG, "Server reloaded.", - errmsg % 'Server', SERVER_RESTART, restart=True) - elif service == 'portal': - print( - "Note: Portal usually doesnt't need to be reloaded unless you " - "are debugging in interactive mode. If Portal was running in " - "default Daemon mode, it cannot be restarted. In that case " - "you have to restart it manually with 'evennia.py " - "start portal'") - kill(PORTAL_PIDFILE, SIG, - "Portal reloaded (or stopped, if it was in daemon mode).", - errmsg % 'Portal', PORTAL_RESTART, restart=True) - else: - # all - # default mode, only restart server - kill(SERVER_PIDFILE, SIG, - "Server reload.", - errmsg % 'Server', SERVER_RESTART, restart=True) - - elif mode == 'stop': - if os.name == "nt": - print ( - "(Obs: You can use a single Ctrl-C to skip " - "Windows' annoying 'Terminate batch job (Y/N)?' prompts.)") - # stop processes, avoiding reload - if service == 'server': - kill(SERVER_PIDFILE, SIG, - "Server stopped.", errmsg % 'Server', SERVER_RESTART) - elif service == 'portal': - kill(PORTAL_PIDFILE, SIG, - "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART) - else: - kill(PORTAL_PIDFILE, SIG, - "Portal stopped.", errmsg % 'Portal', PORTAL_RESTART) - kill(SERVER_PIDFILE, SIG, - "Server stopped.", errmsg % 'Server', SERVER_RESTART) - - def main(): """ Run the evennia launcher main program. """ - # set up argument parser - parser = ArgumentParser(description=CMDLINE_HELP) + parser = ArgumentParser(description=CMDLINE_HELP, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( - '-v', '--version', action='store_true', - dest='show_version', default=False, - help="Show version info.") + '--gamedir', nargs=1, action='store', dest='altgamedir', + metavar="", + help="location of gamedir (default: current location)") parser.add_argument( - '-i', '--interactive', action='store_true', - dest='interactive', default=False, - help="Start given processes in interactive mode.") + '--init', action='store', dest="init", metavar="", + help="creates a new gamedir 'name' at current location") parser.add_argument( - '-l', '--log', action='store_true', - dest="logserver", default=False, - help="Log Server data to log file.") + '--log', '-l', action='store_true', dest='tail_log', default=False, + help="tail the portal and server logfiles and print to stdout") parser.add_argument( - '--init', action='store', dest="init", metavar="name", - help="Creates a new game directory 'name' at the current location.") - parser.add_argument( - '--list', nargs='+', action='store', dest='listsetting', metavar="key", - help=("List values for server settings. Use 'all' to list all " - "available keys.")) - parser.add_argument( - '--profiler', action='store_true', dest='profiler', default=False, - help="Start given server component under the Python profiler.") - parser.add_argument( - '--dummyrunner', nargs=1, action='store', dest='dummyrunner', - metavar="N", - help="Test a running server by connecting N dummy accounts to it.") + '--list', nargs='+', action='store', dest='listsetting', metavar="all|", + help=("list settings, use 'all' to list all available keys")) parser.add_argument( '--settings', nargs=1, action='store', dest='altsettings', - default=None, metavar="filename.py", - help=("Start evennia with alternative settings file from " - "gamedir/server/conf/. (default is settings.py)")) + default=None, metavar="", + help=("start evennia with alternative settings file from\n" + " gamedir/server/conf/. (default is settings.py)")) parser.add_argument( '--initsettings', action='store_true', dest="initsettings", default=False, - help="Create a new, empty settings file as gamedir/server/conf/settings.py.") + help="create a new, empty settings file as\n gamedir/server/conf/settings.py") parser.add_argument( - '--external-runner', action='store_true', dest="doexit", - default=False, - help="Handle server restart with an external process manager.") + '--profiler', action='store_true', dest='profiler', default=False, + help="start given server component under the Python profiler") + parser.add_argument( + '--dummyrunner', nargs=1, action='store', dest='dummyrunner', + metavar="", + help="test a server by connecting dummy accounts to it") + parser.add_argument( + '-v', '--version', action='store_true', + dest='show_version', default=False, + help="show version info") + parser.add_argument( "operation", nargs='?', default="noop", - help="Operation to perform: 'start', 'stop', 'reload' or 'menu'.") - parser.add_argument( - "service", metavar="component", nargs='?', default="all", - help=("Which component to operate on: " - "'server', 'portal' or 'all' (default if not set).")) + help=ARG_OPTIONS) parser.epilog = ( - "Common usage: evennia start|stop|reload. Django-admin database commands:" - "evennia migration|flush|shell|dbshell (see the django documentation for more django-admin commands.)") + "Common Django-admin commands are shell, dbshell, test and migrate.\n" + "See the Django documentation for more management commands.") args, unknown_args = parser.parse_known_args() # handle arguments - option, service = args.operation, args.service + option = args.operation # make sure we have everything check_main_evennia_dependencies() @@ -1255,7 +1866,17 @@ def main(): # show help pane print(CMDLINE_HELP) sys.exit() - elif args.init: + + if args.altgamedir: + # use alternative gamedir path + global GAMEDIR + altgamedir = args.altgamedir[0] + if not os.path.isdir(altgamedir) and not args.init: + print(ERROR_NO_ALT_GAMEDIR.format(gamedir=altgamedir)) + sys.exit() + GAMEDIR = altgamedir + + if args.init: # initialization of game directory create_game_directory(args.init) print(CREATED_NEW_GAMEDIR.format( @@ -1270,8 +1891,8 @@ def main(): if args.altsettings: # use alternative settings file - sfile = args.altsettings[0] global SETTINGSFILE, SETTINGS_DOTPATH, ENFORCED_SETTING + sfile = args.altsettings[0] SETTINGSFILE = sfile ENFORCED_SETTING = True SETTINGS_DOTPATH = "server.conf.%s" % sfile.rstrip(".py") @@ -1280,8 +1901,6 @@ def main(): if args.initsettings: # create new settings file - global GAMEDIR - GAMEDIR = os.getcwd() try: create_settings_file(init=False) print(RECREATED_SETTINGS) @@ -1289,6 +1908,21 @@ def main(): print(ERROR_INITSETTINGS) sys.exit() + if args.tail_log: + # set up for tailing the log files + global NO_REACTOR_STOP + NO_REACTOR_STOP = True + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=False) + + # adjust how many lines we show from existing logs + start_lines1, start_lines2 = 20, 20 + if option not in ('reload', 'reset', 'noop'): + start_lines1, start_lines2 = 0, 0 + + tail_log_files(PORTAL_LOGFILE, SERVER_LOGFILE, start_lines1, start_lines2) + print(" Tailing logfiles {} (Ctrl-C to exit) ...".format( + _file_names_compact(SERVER_LOGFILE, PORTAL_LOGFILE))) if args.dummyrunner: # launch the dummy runner init_game_directory(CURRENT_DIR, check_db=True) @@ -1301,13 +1935,43 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('start', 'reload', 'stop'): + elif option in ('status', 'info', 'start', 'reload', 'reboot', + 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly - init_game_directory(CURRENT_DIR, check_db=True) - server_operation(option, service, args.interactive, args.profiler, args.logserver, doexit=args.doexit) + if not SERVER_LOGFILE: + init_game_directory(CURRENT_DIR, check_db=True) + if option == "status": + query_status() + elif option == "info": + query_info() + elif option == "start": + start_evennia(args.profiler, args.profiler) + elif option == 'reload': + reload_evennia(args.profiler) + elif option == 'reboot': + reboot_evennia(args.profiler, args.profiler) + elif option == 'reset': + reload_evennia(args.profiler, reset=True) + elif option == 'stop': + stop_evennia() + elif option == 'sstop': + stop_server_only() + elif option == 'kill': + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, 'Server') + kill(PORTAL_PIDFILE, 'Portal') + elif option == 'skill': + if _is_windows(): + print("This option is not supported on Windows.") + else: + kill(SERVER_PIDFILE, 'Server') elif option != "noop": # pass-through to django manager check_db = False + + # handle special django commands if option in ('runserver', 'testserver'): print(WARNING_RUNSERVER) if option in ("shell", "check"): @@ -1317,12 +1981,12 @@ def main(): if option == "test": global TEST_MODE TEST_MODE = True + init_game_directory(CURRENT_DIR, check_db=check_db) + # pass on to the manager args = [option] kwargs = {} - if service not in ("all", "server", "portal"): - args.append(service) if unknown_args: for arg in unknown_args: if arg.startswith("--"): @@ -1340,10 +2004,14 @@ def main(): args = ", ".join(args) kwargs = ", ".join(["--%s" % kw for kw in kwargs]) print(ERROR_INPUT.format(traceback=exc, args=args, kwargs=kwargs)) - else: - # no input; print evennia info + + elif not args.tail_log: + # no input; print evennia info (don't pring if we're tailing log) print(ABOUT_INFO) + if REACTOR_RUN: + reactor.run() + if __name__ == '__main__': # start Evennia from the command line diff --git a/evennia/server/evennia_runner.py b/evennia/server/evennia_runner.py deleted file mode 100644 index 83e7bf4093..0000000000 --- a/evennia/server/evennia_runner.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python -""" - -This runner is controlled by the evennia launcher and should normally -not be launched directly. It manages the two main Evennia processes -(Server and Portal) and most importantly runs a passive, threaded loop -that makes sure to restart Server whenever it shuts down. - -Since twistd does not allow for returning an optional exit code we -need to handle the current reload state for server and portal with -flag-files instead. The files, one each for server and portal either -contains True or False indicating if the process should be restarted -upon returning, or not. A process returning != 0 will always stop, no -matter the value of this file. - -""" -from __future__ import print_function -import os -import sys -from argparse import ArgumentParser -from subprocess import Popen -import Queue -import thread -import evennia - -try: - # check if launched with pypy - import __pypy__ as is_pypy -except ImportError: - is_pypy = False - -SERVER = None -PORTAL = None - -EVENNIA_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -EVENNIA_BIN = os.path.join(EVENNIA_ROOT, "bin") -EVENNIA_LIB = os.path.dirname(evennia.__file__) - -SERVER_PY_FILE = os.path.join(EVENNIA_LIB, 'server', 'server.py') -PORTAL_PY_FILE = os.path.join(EVENNIA_LIB, 'server', 'portal', 'portal.py') - -GAMEDIR = None -SERVERDIR = "server" -SERVER_PIDFILE = None -PORTAL_PIDFILE = None -SERVER_RESTART = None -PORTAL_RESTART = None -SERVER_LOGFILE = None -PORTAL_LOGFILE = None -HTTP_LOGFILE = None -PPROFILER_LOGFILE = None -SPROFILER_LOGFILE = None - -# messages - -CMDLINE_HELP = \ - """ - This program manages the running Evennia processes. It is called - by evennia and should not be started manually. Its main task is to - sit and watch the Server and restart it whenever the user reloads. - The runner depends on four files for its operation, two PID files - and two RESTART files for Server and Portal respectively; these - are stored in the game's server/ directory. - """ - -PROCESS_ERROR = \ - """ - {component} process error: {traceback}. - """ - -PROCESS_IOERROR = \ - """ - {component} IOError: {traceback} - One possible explanation is that 'twistd' was not found. - """ - -PROCESS_RESTART = "{component} restarting ..." - -PROCESS_DOEXIT = "Deferring to external runner." - -# Functions - - -def set_restart_mode(restart_file, flag="reload"): - """ - This sets a flag file for the restart mode. - """ - with open(restart_file, 'w') as f: - f.write(str(flag)) - - -def getenv(): - """ - Get current environment and add PYTHONPATH - """ - sep = ";" if os.name == "nt" else ":" - env = os.environ.copy() - sys.path.insert(0, GAMEDIR) - env['PYTHONPATH'] = sep.join(sys.path) - return env - - -def get_restart_mode(restart_file): - """ - Parse the server/portal restart status - """ - if os.path.exists(restart_file): - with open(restart_file, 'r') as f: - return f.read() - return "shutdown" - - -def get_pid(pidfile): - """ - Get the PID (Process ID) by trying to access - an PID file. - """ - pid = None - if os.path.exists(pidfile): - with open(pidfile, 'r') as f: - pid = f.read() - return pid - - -def cycle_logfile(logfile): - """ - Rotate the old log files to .old - """ - logfile_old = logfile + '.old' - if os.path.exists(logfile): - # Cycle the old logfiles to *.old - if os.path.exists(logfile_old): - # E.g. Windows don't support rename-replace - os.remove(logfile_old) - os.rename(logfile, logfile_old) - -# Start program management - - -def start_services(server_argv, portal_argv, doexit=False): - """ - This calls a threaded loop that launches the Portal and Server - and then restarts them when they finish. - """ - global SERVER, PORTAL - processes = Queue.Queue() - - def server_waiter(queue): - try: - rc = Popen(server_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Server", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("server_stopped", rc)) - - def portal_waiter(queue): - try: - rc = Popen(portal_argv, env=getenv()).wait() - except Exception as e: - print(PROCESS_ERROR.format(component="Portal", traceback=e)) - return - # this signals the controller that the program finished - queue.put(("portal_stopped", rc)) - - if portal_argv: - try: - if not doexit and get_restart_mode(PORTAL_RESTART) == "True": - # start portal as interactive, reloadable thread - PORTAL = thread.start_new_thread(portal_waiter, (processes, )) - else: - # normal operation: start portal as a daemon; - # we don't care to monitor it for restart - PORTAL = Popen(portal_argv, env=getenv()) - except IOError as e: - print(PROCESS_IOERROR.format(component="Portal", traceback=e)) - return - - try: - if server_argv: - if doexit: - SERVER = Popen(server_argv, env=getenv()) - else: - # start server as a reloadable thread - SERVER = thread.start_new_thread(server_waiter, (processes, )) - except IOError as e: - print(PROCESS_IOERROR.format(component="Server", traceback=e)) - return - - if doexit: - # Exit immediately - return - - # Reload loop - while True: - - # this blocks until something is actually returned. - from twisted.internet.error import ReactorNotRunning - try: - try: - message, rc = processes.get() - except KeyboardInterrupt: - # this only matters in interactive mode - break - - # restart only if process stopped cleanly - if (message == "server_stopped" and int(rc) == 0 and - get_restart_mode(SERVER_RESTART) in ("True", "reload", "reset")): - print(PROCESS_RESTART.format(component="Server")) - SERVER = thread.start_new_thread(server_waiter, (processes, )) - continue - - # normally the portal is not reloaded since it's run as a daemon. - if (message == "portal_stopped" and int(rc) == 0 and - get_restart_mode(PORTAL_RESTART) == "True"): - print(PROCESS_RESTART.format(component="Portal")) - PORTAL = thread.start_new_thread(portal_waiter, (processes, )) - continue - break - except ReactorNotRunning: - break - - -def main(): - """ - This handles the command line input of the runner, usually created by - the evennia launcher - """ - - parser = ArgumentParser(description=CMDLINE_HELP) - parser.add_argument('--noserver', action='store_true', dest='noserver', - default=False, help='Do not start Server process') - parser.add_argument('--noportal', action='store_true', dest='noportal', - default=False, help='Do not start Portal process') - parser.add_argument('--logserver', action='store_true', dest='logserver', - default=False, help='Log Server output to logfile') - parser.add_argument('--iserver', action='store_true', dest='iserver', - default=False, help='Server in interactive mode') - parser.add_argument('--iportal', action='store_true', dest='iportal', - default=False, help='Portal in interactive mode') - parser.add_argument('--pserver', action='store_true', dest='pserver', - default=False, help='Profile Server') - parser.add_argument('--pportal', action='store_true', dest='pportal', - default=False, help='Profile Portal') - parser.add_argument('--nologcycle', action='store_false', dest='nologcycle', - default=True, help='Do not cycle log files') - parser.add_argument('--doexit', action='store_true', dest='doexit', - default=False, help='Immediately exit after processes have started.') - parser.add_argument('gamedir', help="path to game dir") - parser.add_argument('twistdbinary', help="path to twistd binary") - parser.add_argument('slogfile', help="path to server log file") - parser.add_argument('plogfile', help="path to portal log file") - parser.add_argument('hlogfile', help="path to http log file") - - args = parser.parse_args() - - global GAMEDIR - global SERVER_LOGFILE, PORTAL_LOGFILE, HTTP_LOGFILE - global SERVER_PIDFILE, PORTAL_PIDFILE - global SERVER_RESTART, PORTAL_RESTART - global SPROFILER_LOGFILE, PPROFILER_LOGFILE - - GAMEDIR = args.gamedir - sys.path.insert(1, os.path.join(GAMEDIR, SERVERDIR)) - - SERVER_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "server.pid") - PORTAL_PIDFILE = os.path.join(GAMEDIR, SERVERDIR, "portal.pid") - SERVER_RESTART = os.path.join(GAMEDIR, SERVERDIR, "server.restart") - PORTAL_RESTART = os.path.join(GAMEDIR, SERVERDIR, "portal.restart") - SERVER_LOGFILE = args.slogfile - PORTAL_LOGFILE = args.plogfile - HTTP_LOGFILE = args.hlogfile - TWISTED_BINARY = args.twistdbinary - SPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "server.prof") - PPROFILER_LOGFILE = os.path.join(GAMEDIR, SERVERDIR, "logs", "portal.prof") - - # set up default project calls - server_argv = [TWISTED_BINARY, - '--nodaemon', - '--logfile=%s' % SERVER_LOGFILE, - '--pidfile=%s' % SERVER_PIDFILE, - '--python=%s' % SERVER_PY_FILE] - portal_argv = [TWISTED_BINARY, - '--logfile=%s' % PORTAL_LOGFILE, - '--pidfile=%s' % PORTAL_PIDFILE, - '--python=%s' % PORTAL_PY_FILE] - - # Profiling settings (read file from python shell e.g with - # p = pstats.Stats('server.prof') - pserver_argv = ['--savestats', - '--profiler=cprofile', - '--profile=%s' % SPROFILER_LOGFILE] - pportal_argv = ['--savestats', - '--profiler=cprofile', - '--profile=%s' % PPROFILER_LOGFILE] - - # Server - - pid = get_pid(SERVER_PIDFILE) - if pid and not args.noserver: - print("\nEvennia Server is already running as process %(pid)s. Not restarted." % {'pid': pid}) - args.noserver = True - if args.noserver: - server_argv = None - else: - set_restart_mode(SERVER_RESTART, "shutdown") - if not args.logserver: - # don't log to server logfile - del server_argv[2] - print("\nStarting Evennia Server (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(SERVER_LOGFILE) - print("\nStarting Evennia Server (output to server logfile).") - if args.pserver: - server_argv.extend(pserver_argv) - print("\nRunning Evennia Server under cProfile.") - - # Portal - - pid = get_pid(PORTAL_PIDFILE) - if pid and not args.noportal: - print("\nEvennia Portal is already running as process %(pid)s. Not restarted." % {'pid': pid}) - args.noportal = True - if args.noportal: - portal_argv = None - else: - if args.iportal: - # make portal interactive - portal_argv[1] = '--nodaemon' - set_restart_mode(PORTAL_RESTART, True) - print("\nStarting Evennia Portal in non-Daemon mode (output to stdout).") - else: - if not args.nologcycle: - cycle_logfile(PORTAL_LOGFILE) - cycle_logfile(HTTP_LOGFILE) - set_restart_mode(PORTAL_RESTART, False) - print("\nStarting Evennia Portal in Daemon mode (output to portal logfile).") - if args.pportal: - portal_argv.extend(pportal_argv) - print("\nRunning Evennia Portal under cProfile.") - if args.doexit: - print(PROCESS_DOEXIT) - - # Windows fixes (Windows don't support pidfiles natively) - if os.name == 'nt': - if server_argv: - del server_argv[-2] - if portal_argv: - del portal_argv[-2] - - # Start processes - start_services(server_argv, portal_argv, doexit=args.doexit) - - -if __name__ == '__main__': - main() diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py new file mode 100644 index 0000000000..4ff4732708 --- /dev/null +++ b/evennia/server/portal/amp.py @@ -0,0 +1,418 @@ +""" +The AMP (Asynchronous Message Protocol)-communication commands and constants used by Evennia. + +This module acts as a central place for AMP-servers and -clients to get commands to use. + +""" +from __future__ import print_function +from functools import wraps +import time +from twisted.protocols import amp +from collections import defaultdict, namedtuple +from cStringIO import StringIO +from itertools import count +import zlib # Used in Compressed class +try: + import cPickle as pickle +except ImportError: + import pickle + +from twisted.internet.defer import DeferredList, Deferred +from evennia.utils.utils import to_str, variable_from_module + +# delayed import +_LOGGER = None + +# communication bits +# (chr(9) and chr(10) are \t and \n, so skipping them) + +PCONN = chr(1) # portal session connect +PDISCONN = chr(2) # portal session disconnect +PSYNC = chr(3) # portal session sync +SLOGIN = chr(4) # server session login +SDISCONN = chr(5) # server session disconnect +SDISCONNALL = chr(6) # server session disconnect all +SSHUTD = chr(7) # server shutdown +SSYNC = chr(8) # server session sync +SCONN = chr(11) # server creating new connection (for irc bots and etc) +PCONNSYNC = chr(12) # portal post-syncing a session +PDISCONNALL = chr(13) # portal session disconnect all +SRELOAD = chr(14) # server shutdown in reload mode +SSTART = chr(15) # server start (portal must already be running anyway) +PSHUTD = chr(16) # portal (+server) shutdown +SSHUTD = chr(17) # server shutdown +PSTATUS = chr(18) # ping server or portal status +SRESET = chr(19) # server shutdown in reset mode + +AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed) +BATCH_RATE = 250 # max commands/sec before switching to batch-sending +BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds + +# buffers +_SENDBATCH = defaultdict(list) +_MSGBUFFER = defaultdict(list) + +# resources + +DUMMYSESSION = namedtuple('DummySession', ['sessid'])(0) + + +_HTTP_WARNING = """ +HTTP/1.1 200 OK +Content-Type: text/html + + +This is Evennia's interal AMP port. It handles communication +between Evennia's different processes.

This port should NOT be +publicly visible.

+""".strip() + + +# Helper functions for pickling. + +def dumps(data): + return to_str(pickle.dumps(to_str(data), pickle.HIGHEST_PROTOCOL)) + + +def loads(data): + return pickle.loads(to_str(data)) + + +@wraps +def catch_traceback(func): + "Helper decorator" + def decorator(*args, **kwargs): + try: + func(*args, **kwargs) + except Exception as err: + global _LOGGER + if not _LOGGER: + from evennia.utils import logger as _LOGGER + _LOGGER.log_trace() + raise # make sure the error is visible on the other side of the connection too + print(err) + return decorator + + +# AMP Communication Command types + +class Compressed(amp.String): + """ + This is a custom AMP command Argument that both handles too-long + sends as well as uses zlib for compression across the wire. The + batch-grouping of too-long sends is borrowed from the "mediumbox" + recipy at twisted-hacks's ~glyph/+junk/amphacks/mediumbox. + + """ + + def fromBox(self, name, strings, objects, proto): + """ + Converts from box representation to python. We + group very long data into batches. + """ + value = StringIO() + value.write(strings.get(name)) + for counter in count(2): + # count from 2 upwards + chunk = strings.get("%s.%d" % (name, counter)) + if chunk is None: + break + value.write(chunk) + objects[name] = value.getvalue() + + def toBox(self, name, strings, objects, proto): + """ + Convert from data to box. We handled too-long + batched data and put it together here. + """ + value = StringIO(objects[name]) + strings[name] = value.read(AMP_MAXLEN) + for counter in count(2): + chunk = value.read(AMP_MAXLEN) + if not chunk: + break + strings["%s.%d" % (name, counter)] = chunk + + def toString(self, inObject): + """ + Convert to send on the wire, with compression. + """ + return zlib.compress(inObject, 9) + + def fromString(self, inString): + """ + Convert (decompress) from the wire to Python. + """ + return zlib.decompress(inString) + + +class MsgLauncher2Portal(amp.Command): + """ + Message Launcher -> Portal + + """ + key = "MsgLauncher2Portal" + arguments = [('operation', amp.String()), + ('arguments', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class MsgPortal2Server(amp.Command): + """ + Message Portal -> Server + + """ + key = "MsgPortal2Server" + arguments = [('packed_data', Compressed())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class MsgServer2Portal(amp.Command): + """ + Message Server -> Portal + + """ + key = "MsgServer2Portal" + arguments = [('packed_data', Compressed())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class AdminPortal2Server(amp.Command): + """ + Administration Portal -> Server + + Sent when the portal needs to perform admin operations on the + server, such as when a new session connects or resyncs + + """ + key = "AdminPortal2Server" + arguments = [('packed_data', Compressed())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class AdminServer2Portal(amp.Command): + """ + Administration Server -> Portal + + Sent when the server needs to perform admin operations on the + portal. + + """ + key = "AdminServer2Portal" + arguments = [('packed_data', Compressed())] + errors = {Exception: 'EXCEPTION'} + response = [] + + +class MsgStatus(amp.Command): + """ + Check Status between AMP services + + """ + key = "MsgStatus" + arguments = [('status', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('status', amp.String())] + + +class FunctionCall(amp.Command): + """ + Bidirectional Server <-> Portal + + Sent when either process needs to call an arbitrary function in + the other. This does not use the batch-send functionality. + + """ + key = "FunctionCall" + arguments = [('module', amp.String()), + ('function', amp.String()), + ('args', amp.String()), + ('kwargs', amp.String())] + errors = {Exception: 'EXCEPTION'} + response = [('result', amp.String())] + + +# ------------------------------------------------------------- +# Core AMP protocol for communication Server <-> Portal +# ------------------------------------------------------------- + +class AMPMultiConnectionProtocol(amp.AMP): + """ + AMP protocol that safely handle multiple connections to the same + server without dropping old ones - new clients will receive + all server returns (broadcast). Will also correctly handle + erroneous HTTP requests on the port and return a HTTP error response. + + """ + + # helper methods + + def __init__(self, *args, **kwargs): + """ + Initialize protocol with some things that need to be in place + already before connecting both on portal and server. + + """ + self.send_batch_counter = 0 + self.send_reset_time = time.time() + self.send_mode = True + self.send_task = None + + def dataReceived(self, data): + """ + Handle non-AMP messages, such as HTTP communication. + """ + if data[0] != b'\0': + self.transport.write(_HTTP_WARNING) + self.transport.loseConnection() + else: + super(AMPMultiConnectionProtocol, self).dataReceived(data) + + def makeConnection(self, transport): + """ + Swallow connection log message here. Copied from original + in the amp protocol. + + """ + # copied from original, removing the log message + if not self._ampInitialized: + amp.AMP.__init__(self) + self._transportPeer = transport.getPeer() + self._transportHost = transport.getHost() + amp.BinaryBoxProtocol.makeConnection(self, transport) + + def connectionMade(self): + """ + This is called when an AMP connection is (re-)established. AMP calls it on both sides. + + """ + self.factory.broadcasts.append(self) + + def connectionLost(self, reason): + """ + We swallow connection errors here. The reason is that during a + normal reload/shutdown there will almost always be cases where + either the portal or server shuts down before a message has + returned its (empty) return, triggering a connectionLost error + that is irrelevant. If a true connection error happens, the + portal will continuously try to reconnect, showing the problem + that way. + """ + try: + self.factory.broadcasts.remove(self) + except ValueError: + pass + + # Error handling + + def errback(self, e, info): + """ + Error callback. + Handles errors to avoid dropping connections on server tracebacks. + + Args: + e (Failure): Deferred error instance. + info (str): Error string. + + """ + global _LOGGER + if not _LOGGER: + from evennia.utils import logger as _LOGGER + e.trap(Exception) + _LOGGER.log_err("AMP Error for %(info)s: %(e)s" % {'info': info, + 'e': e.getErrorMessage()}) + + def data_in(self, packed_data): + """ + Process incoming packed data. + + Args: + packed_data (bytes): Zip-packed data. + Returns: + unpaced_data (any): Unpacked package + + """ + return loads(packed_data) + + def broadcast(self, command, sessid, **kwargs): + """ + Send data across the wire to all connections. + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + deferreds = [] + for protcl in self.factory.broadcasts: + deferreds.append(protcl.callRemote(command, **kwargs).addErrback( + self.errback, command.key)) + + return DeferredList(deferreds) + + # generic function send/recvs + + def send_FunctionCall(self, modulepath, functionname, *args, **kwargs): + """ + Access method called by either process. This will call an arbitrary + function on the other process (On Portal if calling from Server and + vice versa). + + Inputs: + modulepath (str) - python path to module holding function to call + functionname (str) - name of function in given module + *args, **kwargs will be used as arguments/keyword args for the + remote function call + Returns: + A deferred that fires with the return value of the remote + function call + + """ + return self.callRemote(FunctionCall, + module=modulepath, + function=functionname, + args=dumps(args), + kwargs=dumps(kwargs)).addCallback( + lambda r: loads(r["result"])).addErrback(self.errback, "FunctionCall") + + @FunctionCall.responder + @catch_traceback + def receive_functioncall(self, module, function, func_args, func_kwargs): + """ + This allows Portal- and Server-process to call an arbitrary + function in the other process. It is intended for use by + plugin modules. + + Args: + module (str or module): The module containing the + `function` to call. + function (str): The name of the function to call in + `module`. + func_args (str): Pickled args tuple for use in `function` call. + func_kwargs (str): Pickled kwargs dict for use in `function` call. + + """ + args = loads(func_args) + kwargs = loads(func_kwargs) + + # call the function (don't catch tracebacks here) + result = variable_from_module(module, function)(*args, **kwargs) + + if isinstance(result, Deferred): + # if result is a deferred, attach handler to properly + # wrap the return value + result.addCallback(lambda r: {"result": dumps(r)}) + return result + else: + return {'result': dumps(result)} diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py new file mode 100644 index 0000000000..c550a648c3 --- /dev/null +++ b/evennia/server/portal/amp_server.py @@ -0,0 +1,455 @@ +""" +The Evennia Portal service acts as an AMP-server, handling AMP +communication to the AMP clients connecting to it (by default +these are the Evennia Server and the evennia launcher). + +""" +import os +import sys +from twisted.internet import protocol +from evennia.server.portal import amp +from django.conf import settings +from subprocess import Popen, STDOUT +from evennia.utils import logger + + +def _is_windows(): + return os.name == 'nt' + + +def getenv(): + """ + Get current environment and add PYTHONPATH. + + Returns: + env (dict): Environment global dict. + + """ + sep = ";" if _is_windows() else ":" + env = os.environ.copy() + env['PYTHONPATH'] = sep.join(sys.path) + return env + + +class AMPServerFactory(protocol.ServerFactory): + + """ + This factory creates AMP Server connection. This acts as the 'Portal'-side communication to the + 'Server' process. + + """ + noisy = False + + def logPrefix(self): + "How this is named in logs" + return "AMP" + + def __init__(self, portal): + """ + Initialize the factory. This is called as the Portal service starts. + + Args: + portal (Portal): The Evennia Portal service instance. + protocol (Protocol): The protocol the factory creates + instances of. + + """ + self.portal = portal + self.protocol = AMPServerProtocol + self.broadcasts = [] + self.server_connection = None + self.launcher_connection = None + self.disconnect_callbacks = {} + self.server_connect_callbacks = [] + + def buildProtocol(self, addr): + """ + Start a new connection, and store it on the service object. + + Args: + addr (str): Connection address. Not used. + + Returns: + protocol (Protocol): The created protocol. + + """ + self.portal.amp_protocol = AMPServerProtocol() + self.portal.amp_protocol.factory = self + return self.portal.amp_protocol + + +class AMPServerProtocol(amp.AMPMultiConnectionProtocol): + """ + Protocol subclass for the AMP-server run by the Portal. + + """ + def connectionLost(self, reason): + """ + Set up a simple callback mechanism to let the amp-server wait for a connection to close. + + """ + # wipe broadcast and data memory + super(AMPServerProtocol, self).connectionLost(reason) + if self.factory.server_connection == self: + self.factory.server_connection = None + self.factory.portal.server_info_dict = {} + if self.factory.launcher_connection == self: + self.factory.launcher_connection = None + + callback, args, kwargs = self.factory.disconnect_callbacks.pop(self, (None, None, None)) + if callback: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace() + + def get_status(self): + """ + Return status for the Evennia infrastructure. + + Returns: + status (tuple): The portal/server status and pids + (portal_live, server_live, portal_PID, server_PID). + + """ + server_connected = bool(self.factory.server_connection and + self.factory.server_connection.transport.connected) + portal_info_dict = self.factory.portal.get_info_dict() + server_info_dict = self.factory.portal.server_info_dict + server_pid = self.factory.portal.server_process_id + portal_pid = os.getpid() + return (True, server_connected, portal_pid, server_pid, portal_info_dict, server_info_dict) + + def data_to_server(self, command, sessid, **kwargs): + """ + Send data across the wire to the Server. + + Args: + command (AMP Command): A protocol send command. + sessid (int): A unique Session id. + + Returns: + deferred (deferred or None): A deferred with an errback. + + Notes: + Data will be sent across the wire pickled as a tuple + (sessid, kwargs). + + """ + if self.factory.server_connection: + return self.factory.server_connection.callRemote( + command, packed_data=amp.dumps((sessid, kwargs))).addErrback( + self.errback, command.key) + else: + # if no server connection is available, broadcast + return self.broadcast(command, sessid, packed_data=amp.dumps((sessid, kwargs))) + + def start_server(self, server_twistd_cmd): + """ + (Re-)Launch the Evennia server. + + Args: + server_twisted_cmd (list): The server start instruction + to pass to POpen to start the server. + + """ + # start the Server + process = None + with open(settings.SERVER_LOG_FILE, 'a') as logfile: + # we link stdout to a file in order to catch + # eventual errors happening before the Server has + # opened its logger. + try: + if _is_windows(): + # Windows requires special care + create_no_window = 0x08000000 + process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1, + stdout=logfile, stderr=STDOUT, + creationflags=create_no_window) + + else: + process = Popen(server_twistd_cmd, env=getenv(), bufsize=-1, + stdout=logfile, stderr=STDOUT) + except Exception: + logger.log_trace() + + self.factory.portal.server_twistd_cmd = server_twistd_cmd + logfile.flush() + if process and not _is_windows(): + # avoid zombie-process on Unix/BSD + process.wait() + return + + def wait_for_disconnect(self, callback, *args, **kwargs): + """ + Add a callback for when this connection is lost. + + Args: + callback (callable): Will be called with *args, **kwargs + once this protocol is disconnected. + + """ + self.factory.disconnect_callbacks[self] = (callback, args, kwargs) + + def wait_for_server_connect(self, callback, *args, **kwargs): + """ + Add a callback for when the Server is sure to have connected. + + Args: + callback (callable): Will be called with *args, **kwargs + once the Server handshake with Portal is complete. + + """ + self.factory.server_connect_callbacks.append((callback, args, kwargs)) + + def stop_server(self, mode='shutdown'): + """ + Shut down server in one or more modes. + + Args: + mode (str): One of 'shutdown', 'reload' or 'reset'. + + """ + if mode == 'reload': + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRELOAD) + elif mode == 'reset': + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SRESET) + elif mode == 'shutdown': + self.send_AdminPortal2Server(amp.DUMMYSESSION, operation=amp.SSHUTD) + self.factory.portal.server_restart_mode = mode + + # sending amp data + + def send_Status2Launcher(self): + """ + Send a status stanza to the launcher. + + """ + if self.factory.launcher_connection: + self.factory.launcher_connection.callRemote( + amp.MsgStatus, + status=amp.dumps(self.get_status())).addErrback( + self.errback, amp.MsgStatus.key) + + def send_MsgPortal2Server(self, session, **kwargs): + """ + Access method called by the Portal and executed on the Portal. + + Args: + session (session): Session + kwargs (any, optional): Optional data. + + Returns: + deferred (Deferred): Asynchronous return. + + """ + return self.data_to_server(amp.MsgPortal2Server, session.sessid, **kwargs) + + def send_AdminPortal2Server(self, session, operation="", **kwargs): + """ + Send Admin instructions from the Portal to the Server. + Executed on the Portal. + + Args: + session (Session): Session. + operation (char, optional): Identifier for the server operation, as defined by the + global variables in `evennia/server/amp.py`. + data (str or dict, optional): Data used in the administrative operation. + + """ + return self.data_to_server(amp.AdminPortal2Server, session.sessid, + operation=operation, **kwargs) + + # receive amp data + + @amp.MsgStatus.responder + @amp.catch_traceback + def portal_receive_status(self, status): + """ + Returns run-status for the server/portal. + + Args: + status (str): Not used. + Returns: + status (dict): The status is a tuple + (portal_running, server_running, portal_pid, server_pid). + + """ + return {"status": amp.dumps(self.get_status())} + + @amp.MsgLauncher2Portal.responder + @amp.catch_traceback + def portal_receive_launcher2portal(self, operation, arguments): + """ + Receives message arriving from evennia_launcher. + This method is executed on the Portal. + + Args: + operation (str): The action to perform. + arguments (str): Possible argument to the instruction, or the empty string. + + Returns: + result (dict): The result back to the launcher. + + Notes: + This is the entrypoint for controlling the entire Evennia system from the evennia + launcher. It can obviously only accessed when the Portal is already up and running. + + """ + self.factory.launcher_connection = self + + _, server_connected, _, _, _, _ = self.get_status() + + # logger.log_msg("Evennia Launcher->Portal operation %s received" % (ord(operation))) + + if operation == amp.SSTART: # portal start #15 + # first, check if server is already running + if not server_connected: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SRELOAD: # reload server #14 + if server_connected: + # We let the launcher restart us once they get the signal + self.factory.server_connection.wait_for_disconnect( + self.send_Status2Launcher) + self.stop_server(mode='reload') + else: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SRESET: # reload server #19 + if server_connected: + self.factory.server_connection.wait_for_disconnect( + self.send_Status2Launcher) + self.stop_server(mode='reset') + else: + self.wait_for_server_connect(self.send_Status2Launcher) + self.start_server(amp.loads(arguments)) + + elif operation == amp.SSHUTD: # server-only shutdown #17 + if server_connected: + self.factory.server_connection.wait_for_disconnect( + self.send_Status2Launcher) + self.stop_server(mode='shutdown') + + elif operation == amp.PSHUTD: # portal + server shutdown #16 + if server_connected: + self.factory.server_connection.wait_for_disconnect( + self.factory.portal.shutdown, restart=False) + else: + self.factory.portal.shutdown(restart=False) + + else: + raise Exception("operation %(op)s not recognized." % {'op': operation}) + + return {} + + @amp.MsgServer2Portal.responder + @amp.catch_traceback + def portal_receive_server2portal(self, packed_data): + """ + Receives message arriving to Portal from Server. + This method is executed on the Portal. + + Args: + packed_data (str): Pickled data (sessid, kwargs) coming over the wire. + + """ + sessid, kwargs = self.data_in(packed_data) + session = self.factory.portal.sessions.get(sessid, None) + if session: + self.factory.portal.sessions.data_out(session, **kwargs) + return {} + + @amp.AdminServer2Portal.responder + @amp.catch_traceback + def portal_receive_adminserver2portal(self, packed_data): + """ + + Receives and handles admin operations sent to the Portal + This is executed on the Portal. + + Args: + packed_data (str): Data received, a pickled tuple (sessid, kwargs). + + """ + self.factory.server_connection = self + + sessid, kwargs = self.data_in(packed_data) + operation = kwargs.pop("operation") + portal_sessionhandler = self.factory.portal.sessions + + if operation == amp.SLOGIN: # server_session_login + # a session has authenticated; sync it. + session = portal_sessionhandler.get(sessid) + if session: + portal_sessionhandler.server_logged_in(session, kwargs.get("sessiondata")) + + elif operation == amp.SDISCONN: # server_session_disconnect + # the server is ordering to disconnect the session + session = portal_sessionhandler.get(sessid) + if session: + portal_sessionhandler.server_disconnect(session, reason=kwargs.get("reason")) + + elif operation == amp.SDISCONNALL: # server_session_disconnect_all + # server orders all sessions to disconnect + portal_sessionhandler.server_disconnect_all(reason=kwargs.get("reason")) + + elif operation == amp.SRELOAD: # server reload + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twistd_cmd) + self.stop_server(mode='reload') + + elif operation == amp.SRESET: # server reset + self.factory.server_connection.wait_for_disconnect( + self.start_server, self.factory.portal.server_twistd_cmd) + self.stop_server(mode='reset') + + elif operation == amp.SSHUTD: # server-only shutdown + self.stop_server(mode='shutdown') + + elif operation == amp.PSHUTD: # full server+server shutdown + self.factory.server_connection.wait_for_disconnect( + self.factory.portal.shutdown, restart=False) + self.stop_server(mode='shutdown') + + elif operation == amp.PSYNC: # portal sync + # Server has (re-)connected and wants the session data from portal + self.factory.portal.server_info_dict = kwargs.get("info_dict", {}) + self.factory.portal.server_process_id = kwargs.get("spid", None) + # this defaults to 'shutdown' or whatever value set in server_stop + server_restart_mode = self.factory.portal.server_restart_mode + + sessdata = self.factory.portal.sessions.get_all_sync_data() + self.send_AdminPortal2Server(amp.DUMMYSESSION, + amp.PSYNC, + server_restart_mode=server_restart_mode, + sessiondata=sessdata) + self.factory.portal.sessions.at_server_connection() + + if self.factory.server_connection: + # this is an indication the server has successfully connected, so + # we trigger any callbacks (usually to tell the launcher server is up) + for callback, args, kwargs in self.factory.server_connect_callbacks: + try: + callback(*args, **kwargs) + except Exception: + logger.log_trace() + self.factory.server_connect_callbacks = [] + + elif operation == amp.SSYNC: # server_session_sync + # server wants to save session data to the portal, + # maybe because it's about to shut down. + portal_sessionhandler.server_session_sync(kwargs.get("sessiondata"), + kwargs.get("clean", True)) + + # set a flag in case we are about to shut down soon + self.factory.server_restart_mode = True + + elif operation == amp.SCONN: # server_force_connection (for irc/etc) + portal_sessionhandler.server_connect(**kwargs) + + else: + raise Exception("operation %(op)s not recognized." % {'op': operation}) + return {} diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 3b658d54fb..a720c73c71 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically by game/evennia.py). """ -from __future__ import print_function from builtins import object import sys @@ -15,6 +14,8 @@ import os from twisted.application import internet, service from twisted.internet import protocol, reactor +from twisted.python.log import ILogObserver + import django django.setup() from django.conf import settings @@ -24,6 +25,7 @@ evennia._init() from evennia.utils.utils import get_evennia_version, mod_import, make_iter from evennia.server.portal.portalsessionhandler import PORTAL_SESSIONS +from evennia.utils import logger from evennia.server.webserver import EvenniaReverseProxyResource from django.db import connection @@ -37,11 +39,6 @@ except Exception: PORTAL_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.PORTAL_SERVICES_PLUGIN_MODULES)] LOCKDOWN_MODE = settings.LOCKDOWN_MODE -PORTAL_PIDFILE = "" -if os.name == 'nt': - # For Windows we need to handle pid files manually. - PORTAL_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'portal.pid') - # ------------------------------------------------------------- # Evennia Portal settings # ------------------------------------------------------------- @@ -77,10 +74,15 @@ AMP_PORT = settings.AMP_PORT AMP_INTERFACE = settings.AMP_INTERFACE AMP_ENABLED = AMP_HOST and AMP_PORT and AMP_INTERFACE +INFO_DICT = {"servername": SERVERNAME, "version": VERSION, "errors": "", "info": "", + "lockdown_mode": "", "amp": "", "telnet": [], "telnet_ssl": [], "ssh": [], + "webclient": [], "webserver_proxy": [], "webserver_internal": []} # ------------------------------------------------------------- # Portal Service object # ------------------------------------------------------------- + + class Portal(object): """ @@ -105,12 +107,19 @@ class Portal(object): self.amp_protocol = None # set by amp factory self.sessions = PORTAL_SESSIONS self.sessions.portal = self + self.process_id = os.getpid() + + self.server_process_id = None + self.server_restart_mode = "shutdown" + self.server_info_dict = {} # set a callback if the server is killed abruptly, # by Ctrl-C, reboot etc. reactor.addSystemEventTrigger('before', 'shutdown', self.shutdown, _reactor_stopping=True) - self.game_running = False + def get_info_dict(self): + "Return the Portal info, for display." + return INFO_DICT def set_restart_mode(self, mode=None): """ @@ -125,7 +134,6 @@ class Portal(object): if mode is None: return with open(PORTAL_RESTART, 'w') as f: - print("writing mode=%(mode)s to %(portal_restart)s" % {'mode': mode, 'portal_restart': PORTAL_RESTART}) f.write(str(mode)) def shutdown(self, restart=None, _reactor_stopping=False): @@ -152,9 +160,7 @@ class Portal(object): return self.sessions.disconnect_all() self.set_restart_mode(restart) - if os.name == 'nt' and os.path.exists(PORTAL_PIDFILE): - # for Windows we need to remove pid files manually - os.remove(PORTAL_PIDFILE) + if not _reactor_stopping: # shutting down the reactor will trigger another signal. We set # a flag to avoid loops. @@ -172,14 +178,18 @@ class Portal(object): # what to execute from. application = service.Application('Portal') +# custom logging +logfile = logger.WeeklyLogFile(os.path.basename(settings.PORTAL_LOG_FILE), + os.path.dirname(settings.PORTAL_LOG_FILE)) +application.setComponent(ILogObserver, logger.PortalLogObserver(logfile).emit) + # The main Portal server program. This sets up the database # and is where we store all the other services. PORTAL = Portal(application) -print('-' * 50) -print(' %(servername)s Portal (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION}) if LOCKDOWN_MODE: - print(' LOCKDOWN_MODE active: Only local connections.') + + INFO_DICT["lockdown_mode"] = ' LOCKDOWN_MODE active: Only local connections.' if AMP_ENABLED: @@ -187,14 +197,14 @@ if AMP_ENABLED: # the portal and the mud server. Only reason to ever deactivate # it would be during testing and debugging. - from evennia.server import amp + from evennia.server.portal import amp_server - print(' amp (to Server): %s' % AMP_PORT) + INFO_DICT["amp"] = 'amp: %s' % AMP_PORT - factory = amp.AmpClientFactory(PORTAL) - amp_client = internet.TCPClient(AMP_HOST, AMP_PORT, factory) - amp_client.setName('evennia_amp') - PORTAL.services.addService(amp_client) + factory = amp_server.AMPServerFactory(PORTAL) + amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) + amp_service.setName("PortalAMPServer") + PORTAL.services.addService(amp_service) # We group all the various services under the same twisted app. @@ -212,7 +222,7 @@ if TELNET_ENABLED: ifacestr = "-%s" % interface for port in TELNET_PORTS: pstring = "%s:%s" % (ifacestr, port) - factory = protocol.ServerFactory() + factory = telnet.TelnetServerFactory() factory.noisy = False factory.protocol = telnet.TelnetProtocol factory.sessionhandler = PORTAL_SESSIONS @@ -220,12 +230,12 @@ if TELNET_ENABLED: telnet_service.setName('EvenniaTelnet%s' % pstring) PORTAL.services.addService(telnet_service) - print(' telnet%s: %s' % (ifacestr, port)) + INFO_DICT["telnet"].append('telnet%s: %s' % (ifacestr, port)) if SSL_ENABLED: - # Start Telnet SSL game connection (requires PyOpenSSL). + # Start Telnet+SSL game connection (requires PyOpenSSL). from evennia.server.portal import telnet_ssl @@ -249,9 +259,10 @@ if SSL_ENABLED: ssl_service.setName('EvenniaSSL%s' % pstring) PORTAL.services.addService(ssl_service) - print(" ssl%s: %s" % (ifacestr, port)) + INFO_DICT["telnet_ssl"].append("telnet+ssl%s: %s" % (ifacestr, port)) else: - print(" ssl%s: %s (deactivated - keys/certificate unset)" % (ifacestr, port)) + INFO_DICT["telnet_ssl"].append( + "telnet+ssl%s: %s (deactivated - keys/cert unset)" % (ifacestr, port)) if SSH_ENABLED: @@ -275,7 +286,7 @@ if SSH_ENABLED: ssh_service.setName('EvenniaSSH%s' % pstring) PORTAL.services.addService(ssh_service) - print(" ssh%s: %s" % (ifacestr, port)) + INFO_DICT["ssh"].append("ssh%s: %s" % (ifacestr, port)) if WEBSERVER_ENABLED: @@ -289,7 +300,6 @@ if WEBSERVER_ENABLED: if interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: ifacestr = "-%s" % interface for proxyport, serverport in WEBSERVER_PORTS: - pstring = "%s:%s<->%s" % (ifacestr, proxyport, serverport) web_root = EvenniaReverseProxyResource('127.0.0.1', serverport, '') webclientstr = "" if WEBCLIENT_ENABLED: @@ -299,7 +309,7 @@ if WEBSERVER_ENABLED: ajax_webclient = webclient_ajax.AjaxWebClient() ajax_webclient.sessionhandler = PORTAL_SESSIONS web_root.putChild("webclientdata", ajax_webclient) - webclientstr = "\n + webclient (ajax only)" + webclientstr = "webclient (ajax only)" if WEBSOCKET_CLIENT_ENABLED and not websocket_started: # start websocket client port for the webclient @@ -307,38 +317,38 @@ if WEBSERVER_ENABLED: from evennia.server.portal import webclient from evennia.utils.txws import WebSocketFactory - interface = WEBSOCKET_CLIENT_INTERFACE + w_interface = WEBSOCKET_CLIENT_INTERFACE + w_ifacestr = '' + if w_interface not in ('0.0.0.0', '::') or len(WEBSERVER_INTERFACES) > 1: + w_ifacestr = "-%s" % interface port = WEBSOCKET_CLIENT_PORT - ifacestr = "" - if interface not in ('0.0.0.0', '::'): - ifacestr = "-%s" % interface - pstring = "%s:%s" % (ifacestr, port) - factory = protocol.ServerFactory() + + class Websocket(protocol.ServerFactory): + "Only here for better naming in logs" + pass + + factory = Websocket() factory.noisy = False factory.protocol = webclient.WebSocketClient factory.sessionhandler = PORTAL_SESSIONS - websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=interface) - websocket_service.setName('EvenniaWebSocket%s' % pstring) + websocket_service = internet.TCPServer(port, WebSocketFactory(factory), interface=w_interface) + websocket_service.setName('EvenniaWebSocket%s:%s' % (w_ifacestr, port)) PORTAL.services.addService(websocket_service) websocket_started = True - webclientstr = "\n + webclient%s" % pstring + webclientstr = "webclient-websocket%s: %s" % (w_ifacestr, port) + INFO_DICT["webclient"].append(webclientstr) web_root = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_root.is_portal = True proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) proxy_service.setName('EvenniaWebProxy%s' % pstring) PORTAL.services.addService(proxy_service) - print(" webproxy%s:%s (<-> %s)%s" % (ifacestr, proxyport, serverport, webclientstr)) + INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport)) + INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) for plugin_module in PORTAL_SERVICES_PLUGIN_MODULES: # external plugin services to start plugin_module.start_plugin_services(PORTAL) - -print('-' * 50) # end of terminal output - -if os.name == 'nt': - # Windows only: Set PID file manually - with open(PORTAL_PIDFILE, 'w') as f: - f.write(str(os.getpid())) diff --git a/evennia/server/portal/ssh.py b/evennia/server/portal/ssh.py index 97d12ada7e..715d99b292 100644 --- a/evennia/server/portal/ssh.py +++ b/evennia/server/portal/ssh.py @@ -39,7 +39,7 @@ from twisted.conch.ssh import common from twisted.conch.insults import insults from twisted.conch.manhole_ssh import TerminalRealm, _Glue, ConchFactory from twisted.conch.manhole import Manhole, recvline -from twisted.internet import defer +from twisted.internet import defer, protocol from twisted.conch import interfaces as iconch from twisted.python import components from django.conf import settings @@ -72,6 +72,15 @@ and put them here: """.format(_PRIVATE_KEY_FILE, _PUBLIC_KEY_FILE) +# not used atm +class SSHServerFactory(protocol.ServerFactory): + "This is only to name this better in logs" + noisy = False + + def logPrefix(self): + return "SSH" + + class SshProtocol(Manhole, session.Session): """ Each account connecting over ssh gets this protocol assigned to diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 558475cd34..955ea5e918 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -8,6 +8,7 @@ sessions etc. """ import re +from twisted.internet import protocol from twisted.internet.task import LoopingCall from twisted.conch.telnet import Telnet, StatefulTelnetProtocol from twisted.conch.telnet import IAC, NOP, LINEMODE, GA, WILL, WONT, ECHO, NULL @@ -26,6 +27,14 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r _IDLE_COMMAND = settings.IDLE_COMMAND + "\n" +class TelnetServerFactory(protocol.ServerFactory): + "This is only to name this better in logs" + noisy = False + + def logPrefix(self): + return "Telnet" + + class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): """ Each player connecting over telnet (ie using most traditional mud @@ -49,10 +58,13 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, Session): # this number is counted down for every handshake that completes. # when it reaches 0 the portal/server syncs their data self.handshakes = 8 # suppress-go-ahead, naws, ttype, mccp, mssp, msdp, gmcp, mxp + self.init_session(self.protocol_key, client_address, self.factory.sessionhandler) + self.protocol_flags["ENCODING"] = settings.ENCODINGS[0] if settings.ENCODINGS else 'utf-8' # add this new connection to sessionhandler so # the Server becomes aware of it. self.sessionhandler.connect(self) + # change encoding to ENCODINGS[0] which reflects Telnet default encoding # suppress go-ahead self.sga = suppress_ga.SuppressGA(self) diff --git a/evennia/server/profiling/dummyrunner.py b/evennia/server/profiling/dummyrunner.py index 34b6acafba..30201be5f1 100644 --- a/evennia/server/profiling/dummyrunner.py +++ b/evennia/server/profiling/dummyrunner.py @@ -106,7 +106,7 @@ ERROR_NO_MIXIN = \ error completely. Warning: Don't run dummyrunner on a production database! It will - create a lot of spammy objects and account accounts! + create a lot of spammy objects and accounts! """ diff --git a/evennia/server/server.py b/evennia/server/server.py index 6e92b0b055..1be9b5be0b 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -7,7 +7,6 @@ sets up all the networking features. (this is done automatically by evennia/server/server_runner.py). """ -from __future__ import print_function from builtins import object import time import sys @@ -17,6 +16,7 @@ from twisted.web import static from twisted.application import internet, service from twisted.internet import reactor, defer from twisted.internet.task import LoopingCall +from twisted.python.log import ILogObserver import django django.setup() @@ -33,6 +33,7 @@ from evennia.server.models import ServerConfig from evennia.server import initial_setup from evennia.utils.utils import get_evennia_version, mod_import, make_iter +from evennia.utils import logger from evennia.comms import channelhandler from evennia.server.sessionhandler import SESSIONS @@ -40,11 +41,6 @@ from django.utils.translation import ugettext as _ _SA = object.__setattr__ -SERVER_PIDFILE = "" -if os.name == 'nt': - # For Windows we need to handle pid files manually. - SERVER_PIDFILE = os.path.join(settings.GAME_DIR, "server", 'server.pid') - # a file with a flag telling the server to restart after shutdown or not. SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", 'server.restart') @@ -53,16 +49,11 @@ SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE) # modules containing plugin services SERVER_SERVICES_PLUGIN_MODULES = [mod_import(module) for module in make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)] -try: - WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) -except ImportError: - WEB_PLUGINS_MODULE = None - print ("WARNING: settings.WEB_PLUGINS_MODULE not found - " - "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.") -#------------------------------------------------------------ + +# ------------------------------------------------------------ # Evennia Server settings -#------------------------------------------------------------ +# ------------------------------------------------------------ SERVERNAME = settings.SERVERNAME VERSION = get_evennia_version() @@ -83,6 +74,17 @@ IRC_ENABLED = settings.IRC_ENABLED RSS_ENABLED = settings.RSS_ENABLED WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED +INFO_DICT = {"servername": SERVERNAME, "version": VERSION, + "amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": ""} + +try: + WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) +except ImportError: + WEB_PLUGINS_MODULE = None + INFO_DICT["errors"] = ( + "WARNING: settings.WEB_PLUGINS_MODULE not found - " + "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf.") + # Maintenance function - this is called repeatedly by the server @@ -172,6 +174,7 @@ class Evennia(object): self.amp_protocol = None # set by amp factory self.sessions = SESSIONS self.sessions.server = self + self.process_id = os.getpid() # Database-specific startup optimizations. self.sqlite3_prep() @@ -192,18 +195,13 @@ class Evennia(object): from twisted.internet.defer import Deferred if hasattr(self, "web_root"): d = self.web_root.empty_threadpool() - d.addCallback(lambda _: self.shutdown(_reactor_stopping=True)) + d.addCallback(lambda _: self.shutdown("shutdown", _reactor_stopping=True)) else: - d = Deferred(lambda _: self.shutdown(_reactor_stopping=True)) + d = Deferred(lambda _: self.shutdown("shutdown", _reactor_stopping=True)) d.addCallback(lambda _: reactor.stop()) reactor.callLater(1, d.callback, None) reactor.sigInt = _wrap_sigint_handler - self.game_running = True - - # track the server time - self.run_init_hooks() - # Server startup methods def sqlite3_prep(self): @@ -211,7 +209,8 @@ class Evennia(object): Optimize some SQLite stuff at startup since we can't save it to the database. """ - if ((".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or + if ((".".join(str(i) for i in django.VERSION) < "1.2" and + settings.DATABASES.get('default', {}).get('ENGINE') == "sqlite3") or (hasattr(settings, 'DATABASES') and settings.DATABASES.get("default", {}).get('ENGINE', None) == 'django.db.backends.sqlite3')): @@ -229,6 +228,8 @@ class Evennia(object): typeclasses in the settings file and have them auto-update all already existing objects. """ + global INFO_DICT + # setting names settings_names = ("CMDSET_CHARACTER", "CMDSET_ACCOUNT", "BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS", @@ -247,7 +248,7 @@ class Evennia(object): #from evennia.accounts.models import AccountDB for i, prev, curr in ((i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches): # update the database - print(" %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr)) + INFO_DICT['info'] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % (settings_names[i], prev, curr) if i == 0: ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update(db_cmdset_storage=curr) if i == 1: @@ -277,29 +278,31 @@ class Evennia(object): It returns if this is not the first time the server starts. Once finished the last_initial_setup_step is set to -1. """ + global INFO_DICT last_initial_setup_step = ServerConfig.objects.conf('last_initial_setup_step') if not last_initial_setup_step: # None is only returned if the config does not exist, # i.e. this is an empty DB that needs populating. - print(' Server started for the first time. Setting defaults.') + INFO_DICT['info'] = ' Server started for the first time. Setting defaults.' initial_setup.handle_setup(0) - print('-' * 50) elif int(last_initial_setup_step) >= 0: # a positive value means the setup crashed on one of its # modules and setup will resume from this step, retrying # the last failed module. When all are finished, the step # is set to -1 to show it does not need to be run again. - print(' Resuming initial setup from step %(last)s.' % - {'last': last_initial_setup_step}) + INFO_DICT['info'] = ' Resuming initial setup from step {last}.'.format( + last=last_initial_setup_step) initial_setup.handle_setup(int(last_initial_setup_step)) - print('-' * 50) - def run_init_hooks(self): + def run_init_hooks(self, mode): """ - Called every server start + Called by the amp client once receiving sync back from Portal + + Args: + mode (str): One of shutdown, reload or reset + """ from evennia.objects.models import ObjectDB - #from evennia.accounts.models import AccountDB # update eventual changed defaults self.update_defaults() @@ -307,47 +310,24 @@ class Evennia(object): [o.at_init() for o in ObjectDB.get_all_cached_instances()] [p.at_init() for p in AccountDB.get_all_cached_instances()] - mode = self.getset_restart_mode() - # call correct server hook based on start file value if mode == 'reload': - # True was the old reload flag, kept for compatibilty + logger.log_msg("Server successfully reloaded.") self.at_server_reload_start() elif mode == 'reset': # only run hook, don't purge sessions self.at_server_cold_start() - elif mode in ('reset', 'shutdown'): + logger.log_msg("Evennia Server successfully restarted in 'reset' mode.") + elif mode == 'shutdown': self.at_server_cold_start() # clear eventual lingering session storages ObjectDB.objects.clear_all_sessids() + logger.log_msg("Evennia Server successfully started.") # always call this regardless of start type self.at_server_start() - def getset_restart_mode(self, mode=None): - """ - This manages the flag file that tells the runner if the server is - reloading, resetting or shutting down. - - Args: - mode (string or None, optional): Valid values are - 'reload', 'reset', 'shutdown' and `None`. If mode is `None`, - no change will be done to the flag file. - Returns: - mode (str): The currently active restart mode, either just - set or previously set. - - """ - if mode is None: - with open(SERVER_RESTART, 'r') as f: - # mode is either shutdown, reset or reload - mode = f.read() - else: - with open(SERVER_RESTART, 'w') as f: - f.write(str(mode)) - return mode - @defer.inlineCallbacks - def shutdown(self, mode=None, _reactor_stopping=False): + def shutdown(self, mode='reload', _reactor_stopping=False): """ Shuts down the server from inside it. @@ -358,7 +338,6 @@ class Evennia(object): at_shutdown hooks called but sessions will not be disconnected. 'shutdown' - like reset, but server will not auto-restart. - None - keep currently set flag from flag file. _reactor_stopping - this is set if server is stopped by a kill command OR this method was already called once - in both cases the reactor is @@ -369,10 +348,7 @@ class Evennia(object): # once; we don't need to run the shutdown procedure again. defer.returnValue(None) - mode = self.getset_restart_mode(mode) - from evennia.objects.models import ObjectDB - #from evennia.accounts.models import AccountDB from evennia.server.models import ServerConfig from evennia.utils import gametime as _GAMETIME_MODULE @@ -381,7 +357,8 @@ class Evennia(object): ServerConfig.objects.conf("server_restart_mode", "reload") yield [o.at_server_reload() for o in ObjectDB.get_all_cached_instances()] yield [p.at_server_reload() for p in AccountDB.get_all_cached_instances()] - yield [(s.pause(manual_pause=False), s.at_server_reload()) for s in ScriptDB.get_all_cached_instances() if s.is_active] + yield [(s.pause(manual_pause=False), s.at_server_reload()) + for s in ScriptDB.get_all_cached_instances() if s.is_active] yield self.sessions.all_sessions_portal_sync() self.at_server_reload_stop() # only save monitor state on reload, not on shutdown/reset @@ -411,10 +388,6 @@ class Evennia(object): # always called, also for a reload self.at_server_stop() - if os.name == 'nt' and os.path.exists(SERVER_PIDFILE): - # for Windows we need to remove pid files manually - os.remove(SERVER_PIDFILE) - if hasattr(self, "web_root"): # not set very first start yield self.web_root.empty_threadpool() @@ -426,6 +399,10 @@ class Evennia(object): # we make sure the proper gametime is saved as late as possible ServerConfig.objects.conf("runtime", _GAMETIME_MODULE.runtime()) + def get_info_dict(self): + "Return the server info, for display." + return INFO_DICT + # server start/stop hooks def at_server_start(self): @@ -451,13 +428,15 @@ class Evennia(object): if SERVER_STARTSTOP_MODULE: SERVER_STARTSTOP_MODULE.at_server_reload_start() - def at_post_portal_sync(self): + def at_post_portal_sync(self, mode): """ This is called just after the portal has finished syncing back data to the server after reconnecting. + + Args: + mode (str): One of reload, reset or shutdown. + """ - # one of reload, reset or shutdown - mode = self.getset_restart_mode() from evennia.scripts.monitorhandler import MONITOR_HANDLER MONITOR_HANDLER.restore(mode == 'reload') @@ -529,13 +508,15 @@ ServerConfig.objects.conf("server_starting_mode", True) # what to execute from. application = service.Application('Evennia') +# custom logging +logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE)) +application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) + # The main evennia server program. This sets up the database # and is where we store all the other services. EVENNIA = Evennia(application) -print('-' * 50) -print(' %(servername)s Server (%(version)s) started.' % {'servername': SERVERNAME, 'version': VERSION}) - if AMP_ENABLED: # The AMP protocol handles the communication between @@ -545,20 +526,20 @@ if AMP_ENABLED: ifacestr = "" if AMP_INTERFACE != '127.0.0.1': ifacestr = "-%s" % AMP_INTERFACE - print(' amp (to Portal)%s: %s' % (ifacestr, AMP_PORT)) - from evennia.server import amp + INFO_DICT["amp"] = 'amp %s: %s' % (ifacestr, AMP_PORT) - factory = amp.AmpServerFactory(EVENNIA) - amp_service = internet.TCPServer(AMP_PORT, factory, interface=AMP_INTERFACE) - amp_service.setName("EvenniaPortal") + from evennia.server import amp_client + + factory = amp_client.AMPClientFactory(EVENNIA) + amp_service = internet.TCPClient(AMP_HOST, AMP_PORT, factory) + amp_service.setName('ServerAMPClient') EVENNIA.services.addService(amp_service) if WEBSERVER_ENABLED: # Start a django-compatible webserver. - #from twisted.python import threadpool from evennia.server.webserver import DjangoWebRoot, WSGIWebServer, Website, LockableThreadPool # start a thread pool and define the root url (/) as a wsgi resource @@ -578,14 +559,16 @@ if WEBSERVER_ENABLED: web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) + web_site.is_portal = False + INFO_DICT["webserver"] = "" for proxyport, serverport in WEBSERVER_PORTS: # create the webserver (we only need the port for this) webserver = WSGIWebServer(threads, serverport, web_site, interface='127.0.0.1') webserver.setName('EvenniaWebServer%s' % serverport) EVENNIA.services.addService(webserver) - print(" webserver: %s" % serverport) + INFO_DICT["webserver"] += "webserver: %s" % serverport ENABLED = [] if IRC_ENABLED: @@ -597,18 +580,11 @@ if RSS_ENABLED: ENABLED.append('rss') if ENABLED: - print(" " + ", ".join(ENABLED) + " enabled.") + INFO_DICT["irc_rss"] = ", ".join(ENABLED) + " enabled." for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: # external plugin protocols plugin_module.start_plugin_services(EVENNIA) -print('-' * 50) # end of terminal output - # clear server startup mode ServerConfig.objects.conf("server_starting_mode", delete=True) - -if os.name == 'nt': - # Windows only: Set PID file manually - with open(SERVER_PIDFILE, 'w') as f: - f.write(str(os.getpid())) diff --git a/evennia/server/session.py b/evennia/server/session.py index 093a2c0d7a..eb77321a24 100644 --- a/evennia/server/session.py +++ b/evennia/server/session.py @@ -7,7 +7,6 @@ from builtins import object import time - #------------------------------------------------------------ # Server Session #------------------------------------------------------------ diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index 5ea0d08c9f..dcb9adb689 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -58,6 +58,12 @@ SSYNC = chr(8) # server session sync SCONN = chr(11) # server portal connection (for bots) PCONNSYNC = chr(12) # portal post-syncing session PDISCONNALL = chr(13) # portal session discnnect all +SRELOAD = chr(14) # server reloading (have portal start a new server) +SSTART = chr(15) # server start (portal must already be running anyway) +PSHUTD = chr(16) # portal (+server) shutdown +SSHUTD = chr(17) # server shutdown +PSTATUS = chr(18) # ping server or portal status +SRESET = chr(19) # server shutdown in reset mode # i18n from django.utils.translation import ugettext as _ @@ -373,8 +379,10 @@ class ServerSessionHandler(SessionHandler): self[sessid] = sess sess.at_sync() + mode = 'reload' + # tell the server hook we synced - self.server.at_post_portal_sync() + self.server.at_post_portal_sync(mode) # announce the reconnection self.announce_all(_(" ... Server restarted.")) @@ -432,13 +440,28 @@ class ServerSessionHandler(SessionHandler): self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN, protocol_path=protocol_path, config=configdict) + def portal_restart_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRELOAD) + + def portal_reset_server(self): + """ + Called by server when reloading. We tell the portal to start a new server instance. + + """ + self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SRESET) + def portal_shutdown(self): """ - Called by server when shutting down the portal. + Called by server when it's time to shut down (the portal will shut us down and then shut + itself down) """ self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, - operation=SSHUTD) + operation=PSHUTD) def login(self, session, account, force=False, testmode=False): """ @@ -564,8 +587,6 @@ class ServerSessionHandler(SessionHandler): sessiondata=session_data, clean=False) - - def disconnect_all_sessions(self, reason="You have been disconnected."): """ Cleanly disconnect all of the connected sessions. diff --git a/evennia/server/webserver.py b/evennia/server/webserver.py index 260b38071d..b1912bf120 100644 --- a/evennia/server/webserver.py +++ b/evennia/server/webserver.py @@ -212,6 +212,12 @@ class Website(server.Site): """ noisy = False + def logPrefix(self): + "How to be named in logs" + if hasattr(self, "is_portal") and self.is_portal: + return "Webserver-proxy" + return "Webserver" + def log(self, request): """Conditional logging""" if _DEBUG: diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 7ae45c4be2..a5c4b7255d 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -65,7 +65,7 @@ ALLOWED_HOSTS = ["*"] # the Portal proxy presents to the world. The serverports are # the internal ports the proxy uses to forward data to the Server-side # webserver (these should not be publicly open) -WEBSERVER_PORTS = [(4001, 4002)] +WEBSERVER_PORTS = [(4001, 4005)] # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. WEBSERVER_INTERFACES = ['0.0.0.0'] # IP addresses that may talk to the server in a reverse proxy configuration, @@ -83,15 +83,20 @@ WEBCLIENT_ENABLED = True # default webclient will use this and only use the ajax version if the browser # is too old to support websockets. Requires WEBCLIENT_ENABLED. WEBSOCKET_CLIENT_ENABLED = True -# Server-side websocket port to open for the webclient. -WEBSOCKET_CLIENT_PORT = 4005 +# Server-side websocket port to open for the webclient. Note that this value will +# be dynamically encoded in the webclient html page to allow the webclient to call +# home. If the external encoded value needs to be different than this, due to +# working through a proxy or docker port-remapping, the environment variable +# WEBCLIENT_CLIENT_PROXY_PORT can be used to override this port only for the +# front-facing client's sake. +WEBSOCKET_CLIENT_PORT = 4002 # Interface addresses to listen to. If 0.0.0.0, listen to all. Use :: for IPv6. WEBSOCKET_CLIENT_INTERFACE = '0.0.0.0' # Actual URL for webclient component to reach the websocket. You only need # to set this if you know you need it, like using some sort of proxy setup. -# If given it must be on the form "ws://hostname" (WEBSOCKET_CLIENT_PORT will -# be automatically appended). If left at None, the client will itself -# figure out this url based on the server's hostname. +# If given it must be on the form "ws[s]://hostname[:port]". If left at None, +# the client will itself figure out this url based on the server's hostname. +# e.g. ws://external.example.com or wss://external.example.com:443 WEBSOCKET_CLIENT_URL = None # This determine's whether Evennia's custom admin page is used, or if the # standard Django admin is used. @@ -166,6 +171,7 @@ IDLE_COMMAND = "idle" # given, this list is tried, in order, aborting on the first match. # Add sets for languages/regions your accounts are likely to use. # (see http://en.wikipedia.org/wiki/Character_encoding) +# Telnet default encoding, unless specified by the client, will be ENCODINGS[0]. ENCODINGS = ["utf-8", "latin-1", "ISO-8859-1"] # Regular expression applied to all output to a given session in order # to strip away characters (usually various forms of decorations) for the benefit @@ -464,7 +470,7 @@ BASE_SCRIPT_TYPECLASS = "typeclasses.scripts.Script" DEFAULT_HOME = "#2" # The start position for new characters. Default is Limbo (#2). # MULTISESSION_MODE = 0, 1 - used by default unloggedin create command -# MULTISESSION_MODE = 2,3 - used by default character_create command +# MULTISESSION_MODE = 2, 3 - used by default character_create command START_LOCATION = "#2" # Lookups of Attributes, Tags, Nicks, Aliases can be aggressively # cached to avoid repeated database hits. This often gives noticeable @@ -498,8 +504,13 @@ TIME_FACTOR = 2.0 # The starting point of your game time (the epoch), in seconds. # In Python a value of 0 means Jan 1 1970 (use negatives for earlier # start date). This will affect the returns from the utils.gametime -# module. +# module. If None, the server's first start-time is used as the epoch. TIME_GAME_EPOCH = None +# Normally, game time will only increase when the server runs. If this is True, +# game time will not pause when the server reloads or goes offline. This setting +# together with a time factor of 1 should keep the game in sync with +# the real time (add a different epoch to shift time) +TIME_IGNORE_DOWNTIMES = False ###################################################################### # Inlinefunc @@ -534,8 +545,8 @@ INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", # 3 - like mode 2, except multiple sessions can puppet one character, each # session getting the same data. MULTISESSION_MODE = 0 -# The maximum number of characters allowed for MULTISESSION_MODE 2,3. This is -# checked by the default ooc char-creation command. Forced to 1 for +# The maximum number of characters allowed for MULTISESSION_MODE 2, 3. +# This is checked by the default ooc char-creation command. Forced to 1 for # MULTISESSION_MODE 0 and 1. MAX_NR_CHARACTERS = 1 # The access hierarchy, in climbing order. A higher permission in the diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 3f8b4cd742..a97a81b1be 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -245,7 +245,7 @@ class AttributeHandler(object): found from cache or database. Notes: When given a category only, a search for all objects - of that cateogory is done and a the category *name* is is + of that cateogory is done and the category *name* is stored. This tells the system on subsequent calls that the list of cached attributes of this category is up-to-date and that the cache can be queried for category matches diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index e6ef778eb8..25488f4987 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -149,6 +149,7 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): # Tag manager methods + def get_tag(self, key=None, category=None, obj=None, tagtype=None, global_search=False): """ Return Tag objects by key, by category, by object (it is @@ -228,25 +229,58 @@ class TypedObjectManager(idmapper.manager.SharedMemoryManager): def get_by_tag(self, key=None, category=None, tagtype=None): """ - Return objects having tags with a given key or category or - combination of the two. + Return objects having tags with a given key or category or combination of the two. + Also accepts multiple tags/category/tagtype Args: - key (str, optional): Tag key. Not case sensitive. - category (str, optional): Tag category. Not case sensitive. - tagtype (str or None, optional): 'type' of Tag, by default + key (str or list, optional): Tag key or list of keys. Not case sensitive. + category (str or list, optional): Tag category. Not case sensitive. If `key` is + a list, a single category can either apply to all keys in that list or this + must be a list matching the `key` list element by element. If no `key` is given, + all objects with tags of this category are returned. + tagtype (str, optional): 'type' of Tag, by default this is either `None` (a normal Tag), `alias` or - `permission`. + `permission`. This always apply to all queried tags. + Returns: objects (list): Objects with matching tag. + + Raises: + IndexError: If `key` and `category` are both lists and `category` is shorter + than `key`. + """ + if not (key or category): + return [] + + keys = make_iter(key) if key else [] + categories = make_iter(category) if category else [] + n_keys = len(keys) + n_categories = len(categories) + dbmodel = self.model.__dbclass__.__name__.lower() - query = [("db_tags__db_tagtype", tagtype), ("db_tags__db_model", dbmodel)] - if key: - query.append(("db_tags__db_key", key.lower())) - if category: - query.append(("db_tags__db_category", category.lower())) - return self.filter(**dict(query)) + query = self.filter(db_tags__db_tagtype__iexact=tagtype, + db_tags__db_model__iexact=dbmodel).distinct() + + if n_keys > 0: + # keys and/or categories given + if n_categories == 0: + categories = [None for _ in range(n_keys)] + elif n_categories == 1 and n_keys > 1: + cat = categories[0] + categories = [cat for _ in range(n_keys)] + elif 1 < n_categories < n_keys: + raise IndexError("get_by_tag needs a single category or a list of categories " + "the same length as the list of tags.") + for ikey, key in enumerate(keys): + query = query.filter(db_tags__db_key__iexact=key, + db_tags__db_category__iexact=categories[ikey]) + else: + # only one or more categories given + for category in categories: + query = query.filter(db_tags__db_category__iexact=category) + + return query def get_by_permission(self, key=None, category=None): """ diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py index e7c3bfdbb1..488dce0f85 100644 --- a/evennia/typeclasses/tags.py +++ b/evennia/typeclasses/tags.py @@ -269,14 +269,15 @@ class TagHandler(object): def get(self, key=None, default=None, category=None, return_tagobj=False, return_list=False): """ - Get the tag for the given key or list of tags. + Get the tag for the given key, category or combination of the two. Args: - key (str or list): The tag or tags to retrieve. + key (str or list, optional): The tag or tags to retrieve. default (any, optional): The value to return in case of no match. category (str, optional): The Tag category to limit the request to. Note that `None` is the valid, default - category. + category. If no `key` is given, all tags of this category will be + returned. return_tagobj (bool, optional): Return the Tag object itself instead of a string representation of the Tag. return_list (bool, optional): Always return a list, regardless diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py new file mode 100644 index 0000000000..b4a2361aae --- /dev/null +++ b/evennia/typeclasses/tests.py @@ -0,0 +1,59 @@ +""" +Unit tests for typeclass base system + +""" + +from evennia.utils.test_resources import EvenniaTest + +# ------------------------------------------------------------ +# Manager tests +# ------------------------------------------------------------ + + +class TestTypedObjectManager(EvenniaTest): + def _manager(self, methodname, *args, **kwargs): + return list(getattr(self.obj1.__class__.objects, methodname)(*args, **kwargs)) + + def test_get_by_tag_no_category(self): + self.obj1.tags.add("tag1") + self.obj1.tags.add("tag2") + self.obj1.tags.add("tag2c") + self.obj2.tags.add("tag2") + self.obj2.tags.add("tag2a") + self.obj2.tags.add("tag2b") + self.obj2.tags.add("tag3 with spaces") + self.obj2.tags.add("tag4") + self.obj2.tags.add("tag2c") + self.assertEquals(self._manager("get_by_tag", "tag1"), [self.obj1]) + self.assertEquals(self._manager("get_by_tag", "tag2"), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", "tag2a"), [self.obj2]) + self.assertEquals(self._manager("get_by_tag", "tag3 with spaces"), [self.obj2]) + self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag2b"]), [self.obj2]) + self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag1"]), []) + self.assertEquals(self._manager("get_by_tag", ["tag2a", "tag4", "tag2c"]), [self.obj2]) + + def test_get_by_tag_and_category(self): + self.obj1.tags.add("tag5", "category1") + self.obj1.tags.add("tag6", ) + self.obj1.tags.add("tag7", "category1") + self.obj1.tags.add("tag6", "category3") + self.obj1.tags.add("tag7", "category4") + self.obj2.tags.add("tag5", "category1") + self.obj2.tags.add("tag5", "category2") + self.obj2.tags.add("tag6", "category3") + self.obj2.tags.add("tag7", "category1") + self.obj2.tags.add("tag7", "category5") + self.assertEquals(self._manager("get_by_tag", "tag5", "category1"), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", "tag6", "category1"), []) + self.assertEquals(self._manager("get_by_tag", "tag6", "category3"), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", ["tag5", "tag6"], + ["category1", "category3"]), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", ["tag5", "tag7"], + "category1"), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", category="category1"), [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", category="category2"), [self.obj2]) + self.assertEquals(self._manager("get_by_tag", category=["category1", "category3"]), + [self.obj1, self.obj2]) + self.assertEquals(self._manager("get_by_tag", category=["category1", "category2"]), + [self.obj2]) + self.assertEquals(self._manager("get_by_tag", category=["category5", "category4"]), []) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 69042e8516..3d8fb6b789 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -63,23 +63,31 @@ menu is immediately exited and the default "look" command is called. text (str, tuple or None): Text shown at this node. If a tuple, the second element in the tuple is a help text to display at this node when the user enters the menu help command there. - options (tuple, dict or None): ( - {'key': name, # can also be a list of aliases. A special key is - # "_default", which marks this option as the default - # fallback when no other option matches the user input. - 'desc': description, # optional description - 'goto': nodekey, # node to go to when chosen. This can also be a callable with - # caller and/or raw_string args. It must return a string - # with the key pointing to the node to go to. - 'exec': nodekey}, # node or callback to trigger as callback when chosen. This - # will execute *before* going to the next node. Both node - # and the explicit callback will be called as normal nodes - # (with caller and/or raw_string args). If the callable/node - # returns a single string (only), this will replace the current - # goto location string in-place (if a goto callback, it will never fire). - # Note that relying to much on letting exec assign the goto - # location can make it hard to debug your menu logic. - {...}, ...) + options (tuple, dict or None): If `None`, this exits the menu. + If a single dict, this is a single-option node. If a tuple, + it should be a tuple of option dictionaries. Option dicts have + the following keys: + - `key` (str or tuple, optional): What to enter to choose this option. + If a tuple, it must be a tuple of strings, where the first string is the + key which will be shown to the user and the others are aliases. + If unset, the options' number will be used. The special key `_default` + marks this option as the default fallback when no other option matches + the user input. There can only be one `_default` option per node. It + will not be displayed in the list. + - `desc` (str, optional): This describes what choosing the option will do. + - `goto` (str, tuple or callable): If string, should be the name of node to go to + when this option is selected. If a callable, it has the signature + `callable(caller[,raw_input][,**kwargs]). If a tuple, the first element + is the callable and the second is a dict with the **kwargs to pass to + the callable. Those kwargs will also be passed into the next node if possible. + Such a callable should return either a str or a (str, dict), where the + string is the name of the next node to go to and the dict is the new, + (possibly modified) kwarg to pass into the next node. + - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above + and runs before it. If given a node name, the node will be executed but will not + be considered the next node. If node/callback returns str or (str, dict), these will + replace the `goto` step (`goto` callbacks will not fire), with the string being the + next node name and the optional dict acting as the kwargs-input for the next node. If key is not given, the option will automatically be identified by its number 1..N. @@ -95,7 +103,7 @@ Example: "This is help text for this node") options = ({"key": "testing", "desc": "Select this to go to node 2", - "goto": "node2", + "goto": ("node2", {"foo": "bar"}), "exec": "callback1"}, {"desc": "Go to node 3.", "goto": "node3"}) @@ -108,12 +116,13 @@ Example: # by the normal 'goto' option key above. caller.msg("Callback called!") - def node2(caller): + def node2(caller, **kwargs): text = ''' This is node 2. It only allows you to go back to the original node1. This extra indent will - be stripped. We don't include a help text. - ''' + be stripped. We don't include a help text but + here are the variables passed to us: {} + '''.format(kwargs) options = {"goto": "node1"} return text, options @@ -148,6 +157,7 @@ evennia.utils.evmenu`. """ from __future__ import print_function +import random from builtins import object, range from textwrap import dedent @@ -260,7 +270,7 @@ class CmdEvMenuNode(Command): err = "Menu object not found as %s.ndb._menutree!" % orig_caller orig_caller.msg(err) # don't give the session as a kwarg here, direct to original raise EvMenuError(err) - # we must do this after the caller with the menui has been correctly identified since it + # we must do this after the caller with the menu has been correctly identified since it # can be either Account, Object or Session (in the latter case this info will be superfluous). caller.ndb._menutree._session = self.session # we have a menu, use it. @@ -359,9 +369,10 @@ class EvMenu(object): re-run with the same input arguments - so be careful if you are counting up some persistent counter or similar - the counter may be run twice if reload happens on the node that does that. - startnode_input (str, optional): Send an input text to `startnode` as if - a user input text from a fictional previous node. When the server reloads, - the latest visited node will be re-run using this kwarg. + startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if + a user input text from a fictional previous node. If including the dict, this will + be passed as **kwargs to that node. When the server reloads, + the latest visited node will be re-run as `node(caller, raw_string, **kwargs)`. session (Session, optional): This is useful when calling EvMenu from an account in multisession mode > 2. Note that this session only really relevant for the very first display of the first node - after that, EvMenu itself @@ -391,6 +402,7 @@ class EvMenu(object): self._startnode = startnode self._menutree = self._parse_menudata(menudata) self._persistent = persistent + self._quitting = False if startnode not in self._menutree: raise EvMenuError("Start node '%s' not in menu tree!" % startnode) @@ -417,6 +429,12 @@ class EvMenu(object): self.nodetext = None self.helptext = None self.options = None + self.nodename = None + self.node_kwargs = {} + + # used for testing + self.test_options = {} + self.test_nodetext = "" # assign kwargs as initialization vars on ourselves. if set(("_startnode", "_menutree", "_session", "_persistent", @@ -463,8 +481,13 @@ class EvMenu(object): menu_cmdset.priority = int(cmdset_priority) self.caller.cmdset.add(menu_cmdset, permanent=persistent) + startnode_kwargs = {} + if isinstance(startnode_input, (tuple, list)) and len(startnode_input) > 1: + startnode_input, startnode_kwargs = startnode_input[:2] + if not isinstance(startnode_kwargs, dict): + raise EvMenuError("startnode_input must be either a str or a tuple (str, dict).") # start the menu - self.goto(self._startnode, startnode_input) + self.goto(self._startnode, startnode_input, **startnode_kwargs) def _parse_menudata(self, menudata): """ @@ -519,7 +542,42 @@ class EvMenu(object): # format the entire node return self.node_formatter(nodetext, optionstext) - def _execute_node(self, nodename, raw_string): + def _safe_call(self, callback, raw_string, **kwargs): + """ + Call a node-like callable, with a variable number of raw_string, *args, **kwargs, all of + which should work also if not present (only `caller` is always required). Return its result. + + """ + try: + try: + nargs = len(getargspec(callback).args) + except TypeError: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + supports_kwargs = bool(getargspec(callback).keywords) + if nargs <= 0: + raise EvMenuError("Callable {} doesn't accept any arguments!".format(callback)) + + if supports_kwargs: + if nargs > 1: + ret = callback(self.caller, raw_string, **kwargs) + # callback accepting raw_string, **kwargs + else: + # callback accepting **kwargs + ret = callback(self.caller, **kwargs) + elif nargs > 1: + # callback accepting raw_string + ret = callback(self.caller, raw_string) + else: + # normal callback, only the caller as arg + ret = callback(self.caller) + except EvMenuError: + errmsg = _ERR_GENERAL.format(nodename=callback) + self.caller.msg(errmsg, self._session) + raise + + return ret + + def _execute_node(self, nodename, raw_string, **kwargs): """ Execute a node. @@ -528,6 +586,7 @@ class EvMenu(object): raw_string (str): The raw default string entered on the previous node (only used if the node accepts it as an argument) + kwargs (any, optional): Optional kwargs for the node. Returns: nodetext, options (tuple): The node text (a string or a @@ -540,47 +599,25 @@ class EvMenu(object): self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError try: - # the node should return data as (text, options) - if len(getargspec(node).args) > 1: - # a node accepting raw_string - nodetext, options = node(self.caller, raw_string) + ret = self._safe_call(node, raw_string, **kwargs) + if isinstance(ret, (tuple, list)) and len(ret) > 1: + nodetext, options = ret[:2] else: - # a normal node, only accepting caller - nodetext, options = node(self.caller) + nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) raise + + # store options to make them easier to test + self.test_options = options + self.test_nodetext = nodetext + return nodetext, options - def display_nodetext(self): - self.caller.msg(self.nodetext, session=self._session) - - def display_helptext(self): - self.caller.msg(self.helptext, session=self._session) - - def callback_goto(self, callback, goto, raw_string): - """ - Call callback and goto in sequence. - - Args: - callback (callable or str): Callback to run before goto. If - the callback returns a string, this is used to replace - the `goto` string before going to the next node. - goto (str): The target node to go to next (unless replaced - by `callable`).. - raw_string (str): The original user input. - - """ - if callback: - # replace goto only if callback returns - goto = self.callback(callback, raw_string) or goto - if goto: - self.goto(goto, raw_string) - - def callback(self, nodename, raw_string): + def run_exec(self, nodename, raw_string, **kwargs): """ Run a function or node as a callback (with the 'exec' option key). @@ -592,6 +629,8 @@ class EvMenu(object): raw_string (str): The raw default string entered on the previous node (only used if the node accepts it as an argument) + kwargs (any): These are optional kwargs passed into goto + Returns: new_goto (str or None): A replacement goto location string or None (no replacement). @@ -602,36 +641,36 @@ class EvMenu(object): relying on this. """ - if callable(nodename): - # this is a direct callable - execute it directly - try: - if len(getargspec(nodename).args) > 1: - # callable accepting raw_string - ret = nodename(self.caller, raw_string) - else: - # normal callable, only the caller as arg - ret = nodename(self.caller) - except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session) - raise - else: - # nodename is a string; lookup as node - try: + try: + if callable(nodename): + # this is a direct callable - execute it directly + ret = self._safe_call(nodename, raw_string, **kwargs) + if isinstance(ret, (tuple, list)): + if not len(ret) > 1 or not isinstance(ret[1], dict): + raise EvMenuError("exec callable must return either None, str or (str, dict)") + ret, kwargs = ret[:2] + else: + # nodename is a string; lookup as node and run as node in-place (don't goto it) # execute the node - ret = self._execute_node(nodename, raw_string) - except EvMenuError as err: - errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string, err) - self.caller.msg("|r%s|n" % errmsg) - logger.log_trace(errmsg) - return + ret = self._execute_node(nodename, raw_string, **kwargs) + if isinstance(ret, (tuple, list)): + if not len(ret) > 1 and ret[1] and not isinstance(ret[1], dict): + raise EvMenuError("exec node must return either None, str or (str, dict)") + ret, kwargs = ret[:2] + except EvMenuError as err: + errmsg = "Error in exec '%s' (input: '%s'): %s" % (nodename, raw_string.rstrip(), err) + self.caller.msg("|r%s|n" % errmsg) + logger.log_trace(errmsg) + return + if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns - return ret + return ret, kwargs return None - def goto(self, nodename, raw_string): + def goto(self, nodename, raw_string, **kwargs): """ - Run a node by name + Run a node by name, optionally dynamically generating that name first. Args: nodename (str or callable): Name of node or a callable @@ -642,24 +681,48 @@ class EvMenu(object): argument) """ - if callable(nodename): - try: - if len(getargspec(nodename).args) > 1: - # callable accepting raw_string - nodename = nodename(self.caller, raw_string) + def _extract_goto_exec(option_dict): + "Helper: Get callables and their eventual kwargs" + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) else: - nodename = nodename(self.caller) - except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=nodename), self._session) - raise + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + + if callable(nodename): + # run the "goto" callable, if possible + inp_nodename = nodename + nodename = self._safe_call(nodename, raw_string, **kwargs) + if isinstance(nodename, (tuple, list)): + if not len(nodename) > 1 or not isinstance(nodename[1], dict): + raise EvMenuError( + "{}: goto callable must return str or (str, dict)".format(inp_nodename)) + nodename, kwargs = nodename[:2] try: - # execute the node, make use of the returns. - nodetext, options = self._execute_node(nodename, raw_string) + # execute the found node, make use of the returns. + nodetext, options = self._execute_node(nodename, raw_string, **kwargs) except EvMenuError: return if self._persistent: - self.caller.attributes.add("_menutree_saved_startnode", (nodename, raw_string)) + self.caller.attributes.add("_menutree_saved_startnode", + (nodename, (raw_string, kwargs))) # validation of the node return values helptext = "" @@ -680,22 +743,25 @@ class EvMenu(object): for inum, dic in enumerate(options): # fix up the option dicts keys = make_iter(dic.get("key")) + desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) - goto, execute = dic.get("goto", None), dic.get("exec", None) - self.default = (goto, execute) + goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + self.default = (goto, goto_kwargs, execute, exec_kwargs) else: + # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - desc = dic.get("desc", dic.get("text", _ERR_NO_OPTION_DESC).strip()) - goto, execute = dic.get("goto", None), dic.get("exec", None) + goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) if keys: display_options.append((keys[0], desc)) for key in keys: if goto or execute: - self.options[strip_ansi(key).strip().lower()] = (goto, execute) + self.options[strip_ansi(key).strip().lower()] = \ + (goto, goto_kwargs, execute, exec_kwargs) self.nodetext = self._format_node(nodetext, display_options) + self.node_kwargs = kwargs + self.nodename = nodename # handle the helptext if helptext: @@ -709,17 +775,44 @@ class EvMenu(object): if not options: self.close_menu() + def run_exec_then_goto(self, runexec, goto, raw_string, runexec_kwargs=None, goto_kwargs=None): + """ + Call 'exec' callback and goto (which may also be a callable) in sequence. + + Args: + runexec (callable or str): Callback to run before goto. If + the callback returns a string, this is used to replace + the `goto` string/callable before being passed into the goto handler. + goto (str): The target node to go to next (may be replaced + by `runexec`).. + raw_string (str): The original user input. + runexec_kwargs (dict, optional): Optional kwargs for runexec. + goto_kwargs (dict, optional): Optional kwargs for goto. + + """ + if runexec: + # replace goto only if callback returns + goto, goto_kwargs = ( + self.run_exec(runexec, raw_string, + **(runexec_kwargs if runexec_kwargs else {})) or + (goto, goto_kwargs)) + if goto: + self.goto(goto, raw_string, **(goto_kwargs if goto_kwargs else {})) + def close_menu(self): """ Shutdown menu; occurs when reaching the end node or using the quit command. """ - self.caller.cmdset.remove(EvMenuCmdSet) - del self.caller.ndb._menutree - if self._persistent: - self.caller.attributes.remove("_menutree_saved") - self.caller.attributes.remove("_menutree_saved_startnode") - if self.cmd_on_exit is not None: - self.cmd_on_exit(self.caller, self) + if not self._quitting: + # avoid multiple calls from different sources + self._quitting = True + self.caller.cmdset.remove(EvMenuCmdSet) + del self.caller.ndb._menutree + if self._persistent: + self.caller.attributes.remove("_menutree_saved") + self.caller.attributes.remove("_menutree_saved_startnode") + if self.cmd_on_exit is not None: + self.cmd_on_exit(self.caller, self) def parse_input(self, raw_string): """ @@ -734,13 +827,13 @@ class EvMenu(object): should also report errors directly to the user. """ - cmd = raw_string.strip().lower() + cmd = strip_ansi(raw_string.strip().lower()) if cmd in self.options: # this will take precedence over the default commands # below - goto, callback = self.options[cmd] - self.callback_goto(callback, goto, raw_string) + goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd] + self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) elif self.auto_look and cmd in ("look", "l"): self.display_nodetext() elif self.auto_help and cmd in ("help", "h"): @@ -748,11 +841,17 @@ class EvMenu(object): elif self.auto_quit and cmd in ("quit", "q", "exit"): self.close_menu() elif self.default: - goto, callback = self.default - self.callback_goto(callback, goto, raw_string) + goto, goto_kwargs, execfunc, exec_kwargs = self.default + self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) else: self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session) + def display_nodetext(self): + self.caller.msg(self.nodetext, session=self._session) + + def display_helptext(self): + self.caller.msg(self.helptext, session=self._session) + # formatters - override in a child class def nodetext_formatter(self, nodetext): @@ -795,16 +894,17 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue + desc_string = ": %s" % desc if desc else "" table_width_max = max(table_width_max, max(m_len(p) for p in key.split("\n")) + - max(m_len(p) for p in desc.split("\n")) + colsep) + max(m_len(p) for p in desc_string.split("\n")) + colsep) raw_key = strip_ansi(key) if raw_key != key: # already decorations in key definition - table.append(" |lc%s|lt%s|le: %s" % (raw_key, key, desc)) + table.append(" |lc%s|lt%s|le%s" % (raw_key, key, desc_string)) else: # add a default white color to key - table.append(" |lc%s|lt|w%s|n|le: %s" % (raw_key, raw_key, desc)) + table.append(" |lc%s|lt|w%s|n|le%s" % (raw_key, raw_key, desc_string)) ncols = (_MAX_TEXT_WIDTH // table_width_max) + 1 # number of ncols @@ -992,6 +1092,10 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): # # ------------------------------------------------------------- +def _generate_goto(caller, **kwargs): + return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"} + + def test_start_node(caller): menu = caller.ndb._menutree text = """ @@ -1016,6 +1120,9 @@ def test_start_node(caller): {"key": ("|yV|niew", "v"), "desc": "View your own name", "goto": "test_view_node"}, + {"key": ("|yD|nynamic", "d"), + "desc": "Dynamic node", + "goto": (_generate_goto, {"name": "test_dynamic_node"})}, {"key": ("|yQ|nuit", "quit", "q", "Q"), "desc": "Quit this menu example.", "goto": "test_end_node"}, @@ -1025,7 +1132,7 @@ def test_start_node(caller): def test_look_node(caller): - text = "" + text = "This is a custom look location!" options = {"key": ("|yL|nook", "l"), "desc": "Go back to the previous menu.", "goto": "test_start_node"} @@ -1052,12 +1159,11 @@ def test_set_node(caller): """) options = {"key": ("back (default)", "_default"), - "desc": "back to main", "goto": "test_start_node"} return text, options -def test_view_node(caller): +def test_view_node(caller, **kwargs): text = """ Your name is |g%s|n! @@ -1067,9 +1173,14 @@ def test_view_node(caller): -always- use numbers (1...N) to refer to listed options also if you don't see a string option key (try it!). """ % caller.key - options = {"desc": "back to main", - "goto": "test_start_node"} - return text, options + if kwargs.get("executed_from_dynamic_node", False): + # we are calling this node as a exec, skip return values + caller.msg("|gCalled from dynamic node:|n \n {}".format(text)) + return + else: + options = {"desc": "back to main", + "goto": "test_start_node"} + return text, options def test_displayinput_node(caller, raw_string): @@ -1085,12 +1196,48 @@ def test_displayinput_node(caller, raw_string): makes it hidden from view. It catches all input (except the in-menu help/quit commands) and will, in this case, bring you back to the start node. - """ % raw_string + """ % raw_string.rstrip() options = {"key": "_default", "goto": "test_start_node"} return text, options +def _test_call(caller, raw_input, **kwargs): + mode = kwargs.get("mode", "exec") + + caller.msg("\n|y'{}' |n_test_call|y function called with\n " + "caller: |n{}\n |yraw_input: \"|n{}|y\" \n kwargs: |n{}\n".format( + mode, caller, raw_input.rstrip(), kwargs)) + + if mode == "exec": + kwargs = {"random": random.random()} + caller.msg("function modify kwargs to {}".format(kwargs)) + else: + caller.msg("|ypassing function kwargs without modification.|n") + + return "test_dynamic_node", kwargs + + +def test_dynamic_node(caller, **kwargs): + text = """ + This is a dynamic node with input: + {} + """.format(kwargs) + options = ({"desc": "pass a new random number to this node", + "goto": ("test_dynamic_node", {"random": random.random()})}, + {"desc": "execute a func with kwargs", + "exec": (_test_call, {"mode": "exec", "test_random": random.random()})}, + {"desc": "dynamic_goto", + "goto": (_test_call, {"mode": "goto", "goto_input": "test"})}, + {"desc": "exec test_view_node with kwargs", + "exec": ("test_view_node", {"executed_from_dynamic_node": True}), + "goto": "test_dynamic_node"}, + {"desc": "back to main", + "goto": "test_start_node"}) + + return text, options + + def test_end_node(caller): text = """ This is the end of the menu and since it has no options the menu diff --git a/evennia/utils/gametime.py b/evennia/utils/gametime.py index 793e348143..3736128819 100644 --- a/evennia/utils/gametime.py +++ b/evennia/utils/gametime.py @@ -19,6 +19,8 @@ from evennia.utils.create import create_script # to real time. TIMEFACTOR = settings.TIME_FACTOR +IGNORE_DOWNTIMES = settings.TIME_IGNORE_DOWNTIMES + # Only set if gametime_reset was called at some point. GAME_TIME_OFFSET = ServerConfig.objects.conf("gametime_offset", default=0) @@ -133,7 +135,10 @@ def gametime(absolute=False): """ epoch = game_epoch() if absolute else 0 - gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR + if IGNORE_DOWNTIMES: + gtime = epoch + (time.time() - server_epoch()) * TIMEFACTOR + else: + gtime = epoch + (runtime() - GAME_TIME_OFFSET) * TIMEFACTOR return gtime diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index ac8c358a76..d28c4e0e26 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -20,6 +20,7 @@ import time from datetime import datetime from traceback import format_exc from twisted.python import log, logfile +from twisted.python import util as twisted_util from twisted.internet.threads import deferToThread @@ -29,10 +30,15 @@ _TIMEZONE = None _CHANNEL_LOG_NUM_TAIL_LINES = None +# logging overrides + + def timeformat(when=None): """ This helper function will format the current time in the same - way as twisted's logger does, including time zone info. + way as the twisted logger does, including time zone info. Only + difference from official logger is that we only use two digits + for the year and don't show timezone for CET times. Args: when (int, optional): This is a time in POSIX seconds on the form @@ -49,14 +55,70 @@ def timeformat(when=None): tz_offset = tz_offset.days * 86400 + tz_offset.seconds # correct given time to utc when = datetime.utcfromtimestamp(when - tz_offset) - tz_hour = abs(int(tz_offset // 3600)) - tz_mins = abs(int(tz_offset // 60 % 60)) - tz_sign = "-" if tz_offset >= 0 else "+" - return '%d-%02d-%02d %02d:%02d:%02d%s%02d%02d' % ( - when.year, when.month, when.day, - when.hour, when.minute, when.second, - tz_sign, tz_hour, tz_mins) + if tz_offset == 0: + tz = "" + else: + tz_hour = abs(int(tz_offset // 3600)) + tz_mins = abs(int(tz_offset // 60 % 60)) + tz_sign = "-" if tz_offset >= 0 else "+" + tz = "%s%02d%s" % (tz_sign, tz_hour, + (":%02d" % tz_mins if tz_mins else "")) + + return '%d-%02d-%02d %02d:%02d:%02d%s' % ( + when.year - 2000, when.month, when.day, + when.hour, when.minute, when.second, tz) + + +class WeeklyLogFile(logfile.DailyLogFile): + """ + Log file that rotates once per week + + """ + day_rotation = 7 + + def shouldRotate(self): + """Rotate when the date has changed since last write""" + # all dates here are tuples (year, month, day) + now = self.toDate() + then = self.lastDate + return now[0] > then[0] or now[1] > then[1] or now[2] > (then[2] + self.day_rotation) + + def write(self, data): + "Write data to log file" + logfile.BaseLogFile.write(self, data) + self.lastDate = max(self.lastDate, self.toDate()) + + +class PortalLogObserver(log.FileLogObserver): + """ + Reformat logging + """ + timeFormat = None + prefix = " |Portal| " + + def emit(self, eventDict): + """ + Copied from Twisted parent, to change logging output + + """ + text = log.textFromEventDict(eventDict) + if text is None: + return + + # timeStr = self.formatTime(eventDict["time"]) + timeStr = timeformat(eventDict["time"]) + fmtDict = { + "text": text.replace("\n", "\n\t")} + + msgStr = log._safeFormat("%(text)s\n", fmtDict) + + twisted_util.untilConcludes(self.write, timeStr + "%s" % self.prefix + msgStr) + twisted_util.untilConcludes(self.flush) + + +class ServerLogObserver(PortalLogObserver): + prefix = " " def log_msg(msg): @@ -124,6 +186,20 @@ def log_err(errmsg): log_errmsg = log_err +def log_server(servermsg): + """ + This is for the Portal to log captured Server stdout messages (it's + usually only used during startup, before Server log is open) + + """ + try: + servermsg = str(servermsg) + except Exception as e: + servermsg = str(e) + for line in servermsg.splitlines(): + log_msg('[Server] %s' % line) + + def log_warn(warnmsg): """ Prints/logs any warnings that aren't critical but should be noted. diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3fe1af496e..86b24e5e4f 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -174,7 +174,7 @@ def _batch_create_object(*objparams): objects (list): A list of created objects Notes: - The `exec` list will execute arbitrary python code so don't allow this to be availble to + The `exec` list will execute arbitrary python code so don't allow this to be available to unprivileged users! """ diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 4436fdccd6..04310c90ed 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -1,24 +1,227 @@ """ Unit tests for the EvMenu system -TODO: This need expansion. +This sets up a testing parent for testing EvMenu trees. It is configured by subclassing the +`TestEvMenu` class from this module and setting the class variables to point to the menu that should +be tested and how it should be called. + +Without adding any further test methods, the tester will process all nodes of the menu, depth first, +by stepping through all options for every node. Optionally, it can check that all nodes are visited. +It will create a hierarchical list of node names that describes the tree structure. This can then be +compared against a template to make sure the menu structure is sound. Easiest way to use this is to +run the test once to see how the structure looks. + +The system also allows for testing the returns of each node as part of the parsing. + +To help debug the menu, turn on `debug_output`, which will print the traversal process in detail. """ +import copy from django.test import TestCase from evennia.utils import evmenu -from mock import Mock +from evennia.utils import ansi +from mock import MagicMock class TestEvMenu(TestCase): "Run the EvMenu testing." + menutree = {} # can also be the path to the menu tree + startnode = "start" + cmdset_mergetype = "Replace" + cmdset_priority = 1 + auto_quit = True + auto_look = True + auto_help = True + cmd_on_exit = "look" + persistent = False + startnode_input = "" + kwargs = {} + + # if all nodes must be visited for the test to pass. This is not on + # by default since there may be exec-nodes that are made to not be + # visited. + expect_all_nodes = False + + # this is compared against the full tree structure generated + expected_tree = [] + # this allows for verifying that a given node returns a given text. The + # text is compared with .startswith, so the entire text need not be matched. + expected_node_texts = {} + # just check the number of options from each node + expected_node_options_count = {} + # check the actual options + expected_node_options = {} + + # set this to print the traversal as it happens (debugging) + debug_output = False + + def _debug_output(self, indent, msg): + if self.debug_output: + print(" " * indent + msg) + + def _test_menutree(self, menu): + """ + This is a automatic tester of the menu tree by recursively progressing through the + structure. + """ + + def _depth_first(menu, tree, visited, indent): + + # we are in a given node here + nodename = menu.nodename + options = menu.test_options + if isinstance(options, dict): + options = (options, ) + + # run validation tests for this node + compare_text = self.expected_node_texts.get(nodename, None) + if compare_text is not None: + compare_text = ansi.strip_ansi(compare_text.strip()) + node_text = menu.test_nodetext + self.assertIsNotNone( + bool(node_text), + "node: {}: node-text is None, which was not expected.".format(nodename)) + node_text = ansi.strip_ansi(node_text.strip()) + self.assertTrue( + node_text.startswith(compare_text), + "\nnode \"{}\':\nOutput:\n{}\n\nExpected (startswith):\n{}".format( + nodename, node_text, compare_text)) + compare_options_count = self.expected_node_options_count.get(nodename, None) + if compare_options_count is not None: + self.assertEqual( + len(options), compare_options_count, + "Not the right number of options returned from node {}.".format(nodename)) + compare_options = self.expected_node_options.get(nodename, None) + if compare_options: + self.assertEqual( + options, compare_options, + "Options returned from node {} does not match.".format(nodename)) + + self._debug_output(indent, "*{}".format(nodename)) + subtree = [] + + if not options: + # an end node + if nodename not in visited: + visited.append(nodename) + subtree = nodename + else: + for inum, optdict in enumerate(options): + + key, desc, execute, goto = optdict.get("key", ""), optdict.get("desc", None),\ + optdict.get("exec", None), optdict.get("goto", None) + + # prepare the key to pass to the menu + if isinstance(key, (tuple, list)) and len(key) > 1: + key = key[0] + if key == "_default": + key = "test raw input" + if not key: + key = str(inum + 1) + + backup_menu = copy.copy(menu) + + # step the menu + menu.parse_input(key) + + # from here on we are likely in a different node + nodename = menu.nodename + + if menu.close_menu.called: + # this was an end node + self._debug_output(indent, " .. menu exited! Back to previous node.") + menu = backup_menu + menu.close_menu = MagicMock() + visited.append(nodename) + subtree.append(nodename) + elif nodename not in visited: + visited.append(nodename) + subtree.append(nodename) + _depth_first(menu, subtree, visited, indent + 2) + #self._debug_output(indent, " -> arrived at {}".format(nodename)) + else: + subtree.append(nodename) + #self._debug_output( indent, " -> arrived at {} (circular call)".format(nodename)) + self._debug_output(indent, "-- {} ({}) -> {}".format(key, desc, goto)) + + if subtree: + tree.append(subtree) + + # the start node has already fired at this point + visited_nodes = [menu.nodename] + traversal_tree = [menu.nodename] + _depth_first(menu, traversal_tree, visited_nodes, 1) + + if self.expect_all_nodes: + self.assertGreaterEqual(len(menu._menutree), len(visited_nodes)) + self.assertEqual(traversal_tree, self.expected_tree) def setUp(self): - self.caller = Mock() - self.caller.msg = Mock() - self.menu = evmenu.EvMenu(self.caller, "evennia.utils.evmenu", startnode="test_start_node", - persistent=True, cmdset_mergetype="Replace", testval="val", - testval2="val2") + self.menu = None + if self.menutree: + self.caller = MagicMock() + self.caller.key = "Test" + self.caller2 = MagicMock() + self.caller2.key = "Test" + self.caller.msg = MagicMock() + self.caller2.msg = MagicMock() + self.session = MagicMock() + self.session2 = MagicMock() + self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode, + cmdset_mergetype=self.cmdset_mergetype, + cmdset_priority=self.cmdset_priority, + auto_quit=self.auto_quit, auto_look=self.auto_look, + auto_help=self.auto_help, + cmd_on_exit=self.cmd_on_exit, persistent=False, + startnode_input=self.startnode_input, session=self.session, + **self.kwargs) + # persistent version + self.pmenu = evmenu.EvMenu(self.caller2, self.menutree, startnode=self.startnode, + cmdset_mergetype=self.cmdset_mergetype, + cmdset_priority=self.cmdset_priority, + auto_quit=self.auto_quit, auto_look=self.auto_look, + auto_help=self.auto_help, + cmd_on_exit=self.cmd_on_exit, persistent=True, + startnode_input=self.startnode_input, session=self.session2, + **self.kwargs) + + self.menu.close_menu = MagicMock() + self.pmenu.close_menu = MagicMock() + + def test_menu_structure(self): + if self.menu: + self._test_menutree(self.menu) + self._test_menutree(self.pmenu) + + +class TestEvMenuExample(TestEvMenu): + + menutree = "evennia.utils.evmenu" + startnode = "test_start_node" + kwargs = {"testval": "val", "testval2": "val2"} + debug_output = False + + expected_node_texts = { + "test_view_node": "Your name is"} + + expected_tree = \ + ['test_start_node', + ['test_set_node', + ['test_start_node'], + 'test_look_node', + ['test_start_node'], + 'test_view_node', + ['test_start_node'], + 'test_dynamic_node', + ['test_dynamic_node', + 'test_dynamic_node', + 'test_dynamic_node', + 'test_dynamic_node', + 'test_start_node'], + 'test_end_node', + 'test_displayinput_node', + ['test_start_node']]] def test_kwargsave(self): self.assertTrue(hasattr(self.menu, "testval")) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 550b11eac3..10621f0feb 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1786,8 +1786,12 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): error = kwargs.get("nofound_string") or _("Could not find '%s'." % query) matches = None elif len(matches) > 1: - error = kwargs.get("multimatch_string") or \ - _("More than one match for '%s' (please narrow target):\n" % query) + multimatch_string = kwargs.get("multimatch_string") + if multimatch_string: + error = "%s\n" % multimatch_string + else: + error = _("More than one match for '%s' (please narrow target):\n" % query) + for num, result in enumerate(matches): # we need to consider Commands, where .aliases is a list aliases = result.aliases.all() if hasattr(result.aliases, "all") else result.aliases diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 27edd79c86..44bc8b3cb3 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -6,6 +6,7 @@ # tuple. # +import os from django.conf import settings from evennia.utils.utils import get_evennia_version @@ -52,7 +53,11 @@ def set_webclient_settings(): global WEBCLIENT_ENABLED, WEBSOCKET_CLIENT_ENABLED, WEBSOCKET_PORT, WEBSOCKET_URL WEBCLIENT_ENABLED = settings.WEBCLIENT_ENABLED WEBSOCKET_CLIENT_ENABLED = settings.WEBSOCKET_CLIENT_ENABLED - WEBSOCKET_PORT = settings.WEBSOCKET_CLIENT_PORT + # if we are working through a proxy or uses docker port-remapping, the webclient port encoded + # in the webclient should be different than the one the server expects. Use the environment + # variable WEBSOCKET_CLIENT_PROXY_PORT if this is the case. + WEBSOCKET_PORT = int(os.environ.get("WEBSOCKET_CLIENT_PROXY_PORT", settings.WEBSOCKET_CLIENT_PORT)) + # this is determined dynamically by the client and is less of an issue WEBSOCKET_URL = settings.WEBSOCKET_CLIENT_URL set_webclient_settings() diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index b2d42891ae..e2b28c3510 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -51,9 +51,9 @@ class TestGeneralContext(TestCase): mock_settings.WEBCLIENT_ENABLED = "webclient" mock_settings.WEBSOCKET_CLIENT_URL = "websocket_url" mock_settings.WEBSOCKET_CLIENT_ENABLED = "websocket_client" - mock_settings.WEBSOCKET_CLIENT_PORT = "websocket_port" + mock_settings.WEBSOCKET_CLIENT_PORT = 5000 general_context.set_webclient_settings() self.assertEqual(general_context.WEBCLIENT_ENABLED, "webclient") self.assertEqual(general_context.WEBSOCKET_URL, "websocket_url") self.assertEqual(general_context.WEBSOCKET_CLIENT_ENABLED, "websocket_client") - self.assertEqual(general_context.WEBSOCKET_PORT, "websocket_port") + self.assertEqual(general_context.WEBSOCKET_PORT, 5000) diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index f31e4c89f1..3852b45273 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -16,9 +16,43 @@ JQuery available. + + + {% block jquery_import %} + + {% endblock %} + + + + + + + diff --git a/requirements.txt b/requirements.txt index be3cf558e5..7f4b94726f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai +inflect diff --git a/win_requirements.txt b/win_requirements.txt index 7012643657..8a130b3268 100644 --- a/win_requirements.txt +++ b/win_requirements.txt @@ -10,3 +10,4 @@ pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai +inflect