From 6fccc9eec0905946ccfbd96e0546b0b07d431d08 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 20 Sep 2020 19:11:11 +0200 Subject: [PATCH 01/34] Sync doc conf --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index be5275d5cc..3016036e46 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -276,7 +276,7 @@ def autodoc_post_process_docstring(app, what, name, obj, options, lines): doc = re.sub(r"```", "", doc, flags=re.MULTILINE) doc = re.sub(r"`{1}", "**", doc, flags=re.MULTILINE) doc = re.sub( - r"^(?P#{1,2})\s*?(?P.*?)$", _sub_header, doc, flags=re.MULTILINE + r"^(?P<hashes>#{1,4})\s*?(?P<title>.*?)$", _sub_header, doc, flags=re.MULTILINE ) newlines = doc.split("\n") From 1dec03c7d63ca92056742718507ebac1a27bf0a4 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 20 Sep 2020 19:56:52 +0200 Subject: [PATCH 02/34] Latest pull from wiki --- docs/source/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/index.md b/docs/source/index.md index 69d53b54b2..2da4c5c11a 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,8 +1,10 @@ + + # VERSION WARNING > This is the experimental static v0.9 documentation of Evennia, _automatically_ generated from the -> [evennia wiki](https://github.com/evennia/evennia/wiki/) at 2020-06-12 22:36:53.868703. +> [evennia wiki](https://github.com/evennia/evennia/wiki/) at 2020-09-20 19:55:46.634441. > There are known conversion issues which will _not_ be addressed in this version - refer to > the original wiki if you have trouble. > From ed8290aec8fc14031e3a05a1680116f7a6280f05 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 20 Sep 2020 20:29:02 +0200 Subject: [PATCH 03/34] Clean up docstrings --- docs/source/Coding-Introduction.md | 2 +- evennia/server/portal/portalsessionhandler.py | 10 ++-- evennia/server/portal/telnet_oob.py | 51 +++++++++++-------- .../server/profiling/dummyrunner_settings.py | 19 ++++--- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/docs/source/Coding-Introduction.md b/docs/source/Coding-Introduction.md index ed6d8e9537..258d9e2a34 100644 --- a/docs/source/Coding-Introduction.md +++ b/docs/source/Coding-Introduction.md @@ -58,5 +58,5 @@ Some people find reading documentation extremely dull and shun it out of princip And finally, of course, have fun! -[feature-request]: (https://github.com/evennia/evennia/issues/new?title=Feature+Request%3a+%3Cdescriptive+title+here%3E&body=%23%23%23%23+Description+of+the+suggested+feature+and+how+it+is+supposed+to+work+for+the+admin%2fend+user%3a%0D%0A%0D%0A%0D%0A%23%23%23%23+A+list+of+arguments+for+why+you+think+this+new+feature+should+be+included+in+Evennia%3a%0D%0A%0D%0A1.%0D%0A2.%0D%0A%0D%0A%23%23%23%23+Extra+information%2c+such+as+requirements+or+ideas+on+implementation%3a%0D%0A%0D%0A +[feature-request]: https://github.com/evennia/evennia/issues/new?title=Feature+Request%3a+%3Cdescriptive+title+here%3E&body=%23%23%23%23+Description+of+the+suggested+feature+and+how+it+is+supposed+to+work+for+the+admin%2fend+user%3a%0D%0A%0D%0A%0D%0A%23%23%23%23+A+list+of+arguments+for+why+you+think+this+new+feature+should+be+included+in+Evennia%3a%0D%0A%0D%0A1.%0D%0A2.%0D%0A%0D%0A%23%23%23%23+Extra+information%2c+such+as+requirements+or+ideas+on+implementation%3a%0D%0A%0D%0A [bug]: https://github.com/evennia/evennia/issues/new?title=Bug%3a+%3Cdescriptive+title+here%3E&body=%23%23%23%23+Steps+to+reproduce+the+issue%3a%0D%0A%0D%0A1.+%0D%0A2.+%0D%0A3.+%0D%0A%0D%0A%23%23%23%23+What+I+expect+to+see+and+what+I+actually+see+%28tracebacks%2c+error+messages+etc%29%3a%0D%0A%0D%0A%0D%0A%0D%0A%23%23%23%23+Extra+information%2c+such+as+Evennia+revision%2frepo%2fbranch%2c+operating+system+and+ideas+for+how+to+solve%3a%0D%0A%0D%0A diff --git a/evennia/server/portal/portalsessionhandler.py b/evennia/server/portal/portalsessionhandler.py index 0f4c6cb560..580e229770 100644 --- a/evennia/server/portal/portalsessionhandler.py +++ b/evennia/server/portal/portalsessionhandler.py @@ -225,16 +225,16 @@ class PortalSessionHandler(SessionHandler): def server_connect(self, protocol_path="", config=dict()): """ Called by server to force the initialization of a new protocol - instance. Server wants this instance to get a unique sessid - and to be connected back as normal. This is used to initiate - irc/rss etc connections. + instance. Server wants this instance to get a unique sessid and to be + connected back as normal. This is used to initiate irc/rss etc + connections. Args: - protocol_path (st): Full python path to the class factory + protocol_path (str): Full python path to the class factory for the protocol used, eg 'evennia.server.portal.irc.IRCClientFactory' config (dict): Dictionary of configuration options, fed as - **kwarg to protocol class' __init__ method. + `**kwarg` to protocol class `__init__` method. Raises: RuntimeError: If The correct factory class is not found. diff --git a/evennia/server/portal/telnet_oob.py b/evennia/server/portal/telnet_oob.py index 7931dcb2a4..baa964918a 100644 --- a/evennia/server/portal/telnet_oob.py +++ b/evennia/server/portal/telnet_oob.py @@ -10,19 +10,22 @@ how and if they are handled. Examples of OOB instructions could be to instruct the client to play sounds or to update a graphical health bar. -> Note that in Evennia's Web client, all send commands are "OOB -commands", (including the "text" one), there is no equivalence to -MSDP/GMCP for the webclient since it doesn't need it. +> Note that in Evennia's Web client, all send commands are "OOB commands", + (including the "text" one), there is no equivalence to MSDP/GMCP for the + webclient since it doesn't need it. This implements the following telnet OOB communication protocols: -- MSDP (Mud Server Data Protocol), as per - http://tintin.sourceforge.net/msdp/ -- GMCP (Generic Mud Communication Protocol) as per - http://www.ironrealms.com/rapture/manual/files/FeatGMCP-txt.html#Generic_MUD_Communication_Protocol%28GMCP%29 -Following the lead of KaVir's protocol snippet, we first check if -client supports MSDP and if not, we fallback to GMCP with a MSDP -header where applicable. +- MSDP (Mud Server Data Protocol), as per + http://tintin.sourceforge.net/msdp/ +- GMCP (Generic Mud Communication Protocol) as per + http://www.ironrealms.com/rapture/manual/files/FeatGMCP-txt.html#Generic_MUD_Communication_Protocol%28GMCP%29 + +Following the lead of KaVir's protocol snippet, we first check if client +supports MSDP and if not, we fallback to GMCP with a MSDP header where +applicable. + +---- """ import re @@ -156,17 +159,17 @@ class TelnetOOB(object): Notes: The output of this encoding will be MSDP structures on these forms: - + ``` [cmdname, [], {}] -> VAR cmdname VAL "" [cmdname, [arg], {}] -> VAR cmdname VAL arg [cmdname, [args],{}] -> VAR cmdname VAL ARRAYOPEN VAL arg VAL arg ... ARRAYCLOSE [cmdname, [], {kwargs}] -> VAR cmdname VAL TABLEOPEN VAR key VAL val ... TABLECLOSE [cmdname, [args], {kwargs}] -> VAR cmdname VAL ARRAYOPEN VAL arg VAL arg ... ARRAYCLOSE VAR cmdname VAL TABLEOPEN VAR key VAL val ... TABLECLOSE - - Further nesting is not supported, so if an array argument - consists of an array (for example), that array will be - json-converted to a string. + ``` + Further nesting is not supported, so if an array argument consists + of an array (for example), that array will be json-converted to a + string. """ msdp_cmdname = "{msdp_var}{msdp_cmdname}{msdp_val}".format( @@ -231,20 +234,24 @@ class TelnetOOB(object): up in the Core package, while Core package names will be stripped on the Evennia side. + ``` [cmd.name, [], {}] -> Cmd.Name [cmd.name, [arg], {}] -> Cmd.Name arg [cmd.name, [args],{}] -> Cmd.Name [args] [cmd.name, [], {kwargs}] -> Cmd.Name {kwargs} [cmdname, [args, {kwargs}] -> Core.Cmdname [[args],{kwargs}] + ``` Notes: There are also a few default mappings between evennia outputcmds and GMCP: - client_options -> Core.Supports.Get - get_inputfuncs -> Core.Commands.Get - get_value -> Char.Value.Get - repeat -> Char.Repeat.Update - monitor -> Char.Monitor.Update + ``` + client_options -> Core.Supports.Get + get_inputfuncs -> Core.Commands.Get + get_value -> Char.Value.Get + repeat -> Char.Repeat.Update + monitor -> Char.Monitor.Update + ``` """ @@ -281,11 +288,13 @@ class TelnetOOB(object): Clients should always send MSDP data on one of the following forms: + ``` cmdname '' -> [cmdname, [], {}] cmdname val -> [cmdname, [val], {}] cmdname array -> [cmdname, [array], {}] cmdname table -> [cmdname, [], {table}] cmdname array cmdname table -> [cmdname, [array], {table}] + ``` Observe that all MSDP_VARS are used to identify cmdnames, so if there are multiple arrays with the same cmdname @@ -380,11 +389,13 @@ class TelnetOOB(object): The following is parsed into Evennia's formal structure: + ``` Core.Name -> [name, [], {}] Core.Name string -> [name, [string], {}] Core.Name [arg, arg,...] -> [name, [args], {}] Core.Name {key:arg, key:arg, ...} -> [name, [], {kwargs}] Core.Name [[args], {kwargs}] -> [name, [args], {kwargs}] + ``` """ if isinstance(data, list): diff --git a/evennia/server/profiling/dummyrunner_settings.py b/evennia/server/profiling/dummyrunner_settings.py index 69f417f56c..d971f35a7d 100644 --- a/evennia/server/profiling/dummyrunner_settings.py +++ b/evennia/server/profiling/dummyrunner_settings.py @@ -14,7 +14,9 @@ ACTIONS - see below ACTIONS is a tuple +``` (login_func, logout_func, (0.3, func1), (0.1, func2) ... ) +``` where the first entry is the function to call on first connect, with a chance of occurring given by CHANCE_OF_LOGIN. This function is usually @@ -33,14 +35,15 @@ returns a string or a list of command strings to execute. Use the client object for optionally saving data between actions. The client object has the following relevant properties and methods: - key - an optional client key. This is only used for dummyrunner output. + +- key - an optional client key. This is only used for dummyrunner output. Default is "Dummy-<cid>" - cid - client id - gid - globally unique id, hashed with time stamp - istep - the current step - exits - an empty list. Can be used to store exit names - objs - an empty list. Can be used to store object names - counter() - returns a unique increasing id, hashed with time stamp +- cid - client id +- gid - globally unique id, hashed with time stamp +- istep - the current step +- exits - an empty list. Can be used to store exit names +- objs - an empty list. Can be used to store object names +- counter() - returns a unique increasing id, hashed with time stamp to make it unique also between dummyrunner instances. The return should either be a single command string or a tuple of @@ -49,6 +52,8 @@ TIMESTEP with a chance given by CHANCE_OF_ACTION by in the order given (no randomness) and allows for setting up a more complex chain of commands (such as creating an account and logging in). +--- + """ # Dummy runner settings From add99c59e9088001a6133a70de0a9ed74316624f Mon Sep 17 00:00:00 2001 From: Brenden Tuck <friarzen@gmail.com> Date: Mon, 21 Sep 2020 11:12:13 -0400 Subject: [PATCH 04/34] update multimedia and supporting plugins to allow message routing --- .../webclient/js/plugins/goldenlayout.js | 47 +++++--- .../static/webclient/js/plugins/multimedia.js | 108 ++++++++++++++---- .../static/webclient/js/plugins/options2.js | 1 + 3 files changed, 118 insertions(+), 38 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js index ec94917a98..afc36c9aa0 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js +++ b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js @@ -404,10 +404,12 @@ let goldenlayout = (function () { // + // returns an array of pane divs that the given message should be sent to // - var onText = function (args, kwargs) { + var routeMessage = function (args, kwargs) { // If the message is not itself tagged, we"ll assume it // should go into any panes with "all" and "untagged" set + var divArray = []; var msgtype = "untagged"; if ( kwargs && "type" in kwargs ) { @@ -419,37 +421,47 @@ let goldenlayout = (function () { } } - let messageDelivered = false; let components = myLayout.root.getItemsByType("component"); - components.forEach( function (component) { - if( component.hasId("inputComponent") ) { return; } // ignore the input component + if( component.hasId("inputComponent") ) { return; } // ignore input components - let textDiv = component.container.getElement().children(".content"); - let attrTypes = textDiv.attr("types"); + let destDiv = component.container.getElement().children(".content"); + let attrTypes = destDiv.attr("types"); let paneTypes = attrTypes ? attrTypes.split(" ") : []; - let updateMethod = textDiv.attr("updateMethod"); - let txt = args[0]; // is this message type listed in this pane"s types (or is this pane catching "all") if( paneTypes.includes(msgtype) || paneTypes.includes("all") ) { - routeMsg( textDiv, txt, updateMethod ); - messageDelivered = true; + divArray.push(destDiv); } // is this pane catching "upmapped" messages? // And is this message type listed in the untagged types array? if( paneTypes.includes("untagged") && untagged.includes(msgtype) ) { - routeMsg( textDiv, txt, updateMethod ); - messageDelivered = true; + divArray.push(destDiv); } }); - if ( messageDelivered ) { - return true; - } - // unhandled message - return false; + return divArray; + } + + + // + // + var onText = function (args, kwargs) { + // are any panes set to receive this text message? + var divs = routeMessage(args, kwargs); + + var msgHandled = false; + divs.forEach( function (div) { + let updateMethod = div.attr("updateMethod"); + let txt = args[0]; + + // yes, so add this text message to the target div + routeMsg( div, txt, updateMethod ); + msgHandled = true; + }); + + return msgHandled; } @@ -536,6 +548,7 @@ let goldenlayout = (function () { getGL: function () { return myLayout; }, addKnownType: addKnownType, onTabCreate: onTabCreate, + routeMessage: routeMessage, } }()); window.plugin_handler.add("goldenlayout", goldenlayout); diff --git a/evennia/web/webclient/static/webclient/js/plugins/multimedia.js b/evennia/web/webclient/static/webclient/js/plugins/multimedia.js index 59cdc3a4c8..f8f6ab10d3 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/multimedia.js +++ b/evennia/web/webclient/static/webclient/js/plugins/multimedia.js @@ -1,53 +1,119 @@ /* + * Evennia example Webclient multimedia outputs plugin * - * Evennia Webclient multimedia outputs plugin + * PLUGIN ORDER PREREQS: + * loaded after: + * webclient_gui.js + * option2.js + * loaded before: * - * in evennia python code: * + * To use, in evennia python code: * target.msg( image="URL" ) * target.msg( audio="URL" ) * target.msg( video="URL" ) + * or, if you prefer tagged routing: + * target.msg( image=("URL",{'type':'tag'}) ) * + * + * Note: users probably don't _want_ more than one pane to end up with multimedia tags... + * But to allow proper tagged message routing, this plugin doesn't explicitly deny it. */ let multimedia_plugin = (function () { // var image = function (args, kwargs) { - var mwin = $("#messagewindow"); - mwin.append("<img src='"+ args[0] +"'/>"); - mwin.scrollTop(mwin[0].scrollHeight); + let options = window.options; + if( !("mm_image" in options) || options["mm_image"] === false ) { return; } + + var mwins = window.plugins["goldenlayout"].routeMessage(args, kwargs); + mwins.forEach( function (mwin) { + mwin.append("<img src='"+ args[0] +"'/>"); + mwin.scrollTop(mwin[0].scrollHeight); + }); } + // var audio = function (args, kwargs) { + let options = window.options; + if( !("mm_audio" in options) || options["mm_audio"] === false ) { return; } + // create an HTML5 audio control (only .mp3 is fully compatible with all major browsers) - var mwin = $("#messagewindow"); - mwin.append("<audio controls='' autoplay='' style='height:17px;width:175px'>" + - "<source src='"+ args[0] +"'/>" + - "</audio>"); - mwin.scrollTop(mwin[0].scrollHeight); + var mwins = window.plugins["goldenlayout"].routeMessage(args, kwargs); + mwins.forEach( function (mwin) { + mwin.append("<audio controls='' autoplay='' style='height:17px;width:175px'>" + + "<source src='"+ args[0] +"'/>" + + "</audio>"); + mwin.scrollTop(mwin[0].scrollHeight); + }); } + // var video = function (args, kwargs) { + let options = window.options; + if( !("mm_video" in options) || options["mm_video"] === false ) { return; } + // create an HTML5 video element (only h264 .mp4 is compatible with all major browsers) - var mwin = $("#messagewindow"); - mwin.append("<video controls='' autoplay=''>" + - "<source src='"+ args[0] +"'/>" + - "</video>"); - mwin.scrollTop(mwin[0].scrollHeight); + var mwins = window.plugins["goldenlayout"].routeMessage(args, kwargs); + mwins.forEach( function (mwin) { + mwin.append("<video controls='' autoplay=''>" + + "<source src='"+ args[0] +"'/>" + + "</video>"); + mwin.scrollTop(mwin[0].scrollHeight); + }); + } + + // + var onOptionsUI = function (parentdiv) { + let options = window.options; + var checked; + + checked = options["mm_image"] ? "checked='checked'" : ""; + var mmImage = $( [ "<label>", + "<input type='checkbox' data-setting='mm_image' " + checked + "'>", + " Enable multimedia image (png/gif/etc) messages", + "</label>" + ].join("") ); + + checked = options["mm_audio"] ? "checked='checked'" : ""; + var mmAudio = $( [ "<label>", + "<input type='checkbox' data-setting='mm_audio' " + checked + "'>", + " Enable multimedia audio (mp3) messages", + "</label>" + ].join("") ); + + checked = options["mm_video"] ? "checked='checked'" : ""; + var mmVideo = $( [ "<label>", + "<input type='checkbox' data-setting='mm_video' " + checked + "'>", + " Enable multimedia video (h264 .mp4) messages", + "</label>" + ].join("") ); + mmImage.on("change", window.plugins["options2"].onOptionCheckboxChanged); + mmAudio.on("change", window.plugins["options2"].onOptionCheckboxChanged); + mmVideo.on("change", window.plugins["options2"].onOptionCheckboxChanged); + + parentdiv.append(mmImage); + parentdiv.append(mmAudio); + parentdiv.append(mmVideo); } // // Mandatory plugin init function var init = function () { - Evennia = window.Evennia; - Evennia.emitter.on('image', image); // capture "image" commands - Evennia.emitter.on('audio', audio); // capture "audio" commands - Evennia.emitter.on('video', video); // capture "video" commands + let options = window.options; + options["mm_image"] = true; + options["mm_audio"] = true; + options["mm_video"] = true; + + let Evennia = window.Evennia; + Evennia.emitter.on("image", image); // capture "image" commands + Evennia.emitter.on("audio", audio); // capture "audio" commands + Evennia.emitter.on("video", video); // capture "video" commands console.log('Multimedia plugin initialized'); } return { init: init, + onOptionsUI: onOptionsUI, } })(); -plugin_handler.add('multimedia_plugin', multimedia_plugin); - +plugin_handler.add("multimedia_plugin", multimedia_plugin); diff --git a/evennia/web/webclient/static/webclient/js/plugins/options2.js b/evennia/web/webclient/static/webclient/js/plugins/options2.js index eb3bc31bc2..2e5ff4a410 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/options2.js +++ b/evennia/web/webclient/static/webclient/js/plugins/options2.js @@ -179,6 +179,7 @@ let options2 = (function () { onLoggedIn: onLoggedIn, onOptionsUI: onOptionsUI, onPrompt: onPrompt, + onOptionCheckboxChanged: onOptionCheckboxChanged, } })(); window.plugin_handler.add("options2", options2); From ee0fedfdf718d53cd924757c5bbc9b4ae4c83e53 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Wed, 23 Sep 2020 19:20:41 +0200 Subject: [PATCH 05/34] Add some fixes to the tutorial_world. --- evennia/contrib/tutorial_world/build.ev | 10 +++++----- evennia/contrib/tutorial_world/objects.py | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev index 8780400e6c..b120ff277e 100644 --- a/evennia/contrib/tutorial_world/build.ev +++ b/evennia/contrib/tutorial_world/build.ev @@ -294,14 +294,14 @@ start on the sign. # Set a climbable object for discovering a hidden exit # -@create/drop gnarled old trees;tree;trees;gnarled : tutorial_world.objects.TutorialClimbable +@create/drop gnarled old tree;tree;trees;gnarled : tutorial_world.objects.TutorialClimbable # -@desc trees = Only the sturdiest of trees survive at the edge of the - moor. A small group of huddling black things has dug in near the +@desc tree = Only the sturdiest of trees survive at the edge of the + moor. A small huddling black thing has dug in near the cliff edge, eternally pummeled by wind and salt to become an integral part of the gloomy scenery. # -@lock trees = get:false() +@lock tree = get:false() # @set trees/get_err_msg = The group of old trees have withstood the eternal wind for hundreds @@ -475,7 +475,7 @@ north # regular exits back to the cliff, that is handled by the bridge # typeclass itself. # -@dig The old bridge;bridge;tut#05 +@dig The old bridge;bridge;east;e;tut#05 : tutorial_world.rooms.BridgeRoom = old bridge;east;e;bridge;hangbridge # diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 512eca91c2..aa80bb6f3c 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -647,7 +647,7 @@ class CrumblingWall(TutorialObject, DefaultExit): """called when the object is first created.""" super().at_object_creation() - self.aliases.add(["secret passage", "passage", "crack", "opening", "secret door"]) + self.aliases.add(["secret passage", "passage", "crack", "opening", "secret"]) # starting root positions. H1/H2 are the horizontally hanging roots, # V1/V2 the vertically hanging ones. Each can have three positions: @@ -688,6 +688,7 @@ class CrumblingWall(TutorialObject, DefaultExit): # start a 45 second timer before closing again. We store the deferred so it can be # killed in unittesting. self.deferred = delay(45, self.reset) + return True def _translate_position(self, root, ipos): """Translates the position into words""" @@ -740,7 +741,7 @@ class CrumblingWall(TutorialObject, DefaultExit): "The wall is old and covered with roots that here and there have permeated the stone. " "The roots (or whatever they are - some of them are covered in small nondescript flowers) " "crisscross the wall, making it hard to clearly see its stony surface. Maybe you could " - "try to |wshift|n or |wmove|n them.\n" + "try to |wshift|n or |wmove|n them (like '|wshift red up|n').\n" ] # display the root positions to help with the puzzle for key, pos in self.db.root_pos.items(): From ff59163d2219490ec4d6a20d507e5b85302377f6 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Wed, 23 Sep 2020 21:14:00 +0200 Subject: [PATCH 06/34] Add abort command to tutorial-world and some bugfixes --- evennia/contrib/tutorial_world/build.ev | 21 ++++------- evennia/contrib/tutorial_world/rooms.py | 49 +++++++++++++++++++++---- evennia/utils/evmenu.py | 2 +- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev index b120ff277e..2ecd4fd121 100644 --- a/evennia/contrib/tutorial_world/build.ev +++ b/evennia/contrib/tutorial_world/build.ev @@ -104,16 +104,8 @@ tutorial # ... and describe it. # @desc - |gWelcome to the Evennia tutorial!|n - - - The following tutorial consists of a small single-player quest - area. The various rooms are designed to show off some of the power - and possibilities of the Evennia mud creation system. At any time - during this tutorial you can use the |wtutorial|n (or |wtut|n) - command to get some background info about the room or certain objects - to see what is going on "behind the scenes". - + |gWelcome to the Evennia tutorial-world!|n + This small quest shows some examples of Evennia usage. To get into the mood of this miniature quest, imagine you are an adventurer out to find fame and fortune. You have heard rumours of an @@ -126,14 +118,17 @@ tutorial and rain screaming in your face you stand where the moor meet the sea along a high, rocky coast ... +Try 'tutorial' to get behind-the-scenes help anywhere, and 'give up' +if you want to abort. + +|gwrite 'begin' to start your quest!|n + - |g(write 'start' or 'begin' to start the tutorial. Try 'tutorial' - to get behind-the-scenes help anywhere.)|n # # Show that the tutorial command works ... # @set here/tutorial_info = - You just tried the tutorial command. Use it in various rooms to see + You just tried the |wtutorial|G command. Use it in various rooms to see what's technically going on and what you could try in each room. The intro room assigns some properties to your character, like a simple "health" property used when fighting. Other rooms and puzzles might do diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index 013ce26d9f..fa7840683b 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -69,12 +69,14 @@ class CmdTutorial(Command): target = caller.search(self.args.strip()) if not target: return - helptext = target.db.tutorial_info - if helptext: - caller.msg("|G%s|n" % helptext) - else: - caller.msg("|RSorry, there is no tutorial help available here.|n") + helptext = target.db.tutorial_info or "" + if helptext: + helptext = f" |G{helptext}|n" + else: + helptext = " |RSorry, there is no tutorial help available here.|n" + helptext += "\n\n (Write 'give up' if you want to abandon your quest.)" + caller.msg(helptext) # for the @detail command we inherit from MuxCommand, since # we want to make use of MuxCommand's pre-parsing of '=' in the @@ -200,6 +202,26 @@ class CmdTutorialLook(default_cmds.CmdLook): looking_at_obj.at_desc(looker=caller) return +class CmdTutorialGiveUp(default_cmds.MuxCommand): + """ + Give up the tutorial-world quest and return to Limbo, the start room of the + server. + + """ + key = "give up" + aliases = ['abort'] + + def func(self): + outro_room = OutroRoom.objects.all() + if outro_room: + outro_room = outro_room[0] + else: + self.caller.msg("That didn't work (seems like a bug). " + "Try to use the |wteleport|n command instead.") + return + + self.caller.move_to(outro_room) + class TutorialRoomCmdSet(CmdSet): """ @@ -216,6 +238,7 @@ class TutorialRoomCmdSet(CmdSet): self.add(CmdTutorial()) self.add(CmdTutorialSetDetail()) self.add(CmdTutorialLook()) + self.add(CmdTutorialGiveUp()) class TutorialRoom(DefaultRoom): @@ -396,7 +419,12 @@ class IntroRoom(TutorialRoom): if character.is_superuser: string = "-" * 78 + SUPERUSER_WARNING + "-" * 78 - character.msg("|r%s|n" % string.format(name=character.key, quell="|w@quell|r")) + character.msg("|r%s|n" % string.format(name=character.key, quell="|wquell|r")) + else: + # quell user + if character.account: + character.account.execute_cmd("quell") + character.msg("(Auto-quelling while in tutorial-world)") # ------------------------------------------------------------- @@ -617,7 +645,7 @@ class BridgeCmdSet(CmdSet): """This groups the bridge commands. We will store it on the room.""" key = "Bridge commands" - priority = 1 # this gives it precedence over the normal look/help commands. + priority = 2 # this gives it precedence over the normal look/help commands. def at_cmdset_creation(self): """Called at first cmdset creation""" @@ -679,7 +707,7 @@ class BridgeRoom(WeatherRoom): self.db.east_exit = "gate" self.db.fall_exit = "cliffledge" # add the cmdset on the room. - self.cmdset.add_default(BridgeCmdSet) + self.cmdset.add(BridgeCmdSet, permanent=True) # since the default Character's at_look() will access the room's # return_description (this skips the cmdset) when # first entering it, we need to explicitly turn off the room @@ -1108,3 +1136,8 @@ class OutroRoom(TutorialRoom): if obj.typeclass_path.startswith("evennia.contrib.tutorial_world"): obj.delete() character.tags.clear(category="tutorial_world") + + def at_object_leave(self, character, destination): + if character.account: + character.account.execute_cmd("unquell") + diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index ccf2840f91..f60c0138d7 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -321,7 +321,7 @@ class EvMenuCmdSet(CmdSet): # ------------------------------------------------------------- -class EvMenu(object): +class EvMenu: """ This object represents an operational menu. It is initialized from a menufile.py instruction. From 1c7a6dfd21988bd51ced34a85d7abf8934c70e25 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 11:18:23 +0200 Subject: [PATCH 07/34] Cleaner error message passing reserved kwarg to EvMenu --- evennia/utils/evmenu.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index f60c0138d7..ea61ec11a8 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -477,7 +477,7 @@ class EvMenu: self.test_nodetext = "" # assign kwargs as initialization vars on ourselves. - if set( + reserved_clash = set( ( "_startnode", "_menutree", @@ -491,9 +491,10 @@ class EvMenu: "cmdset_mergetype", "auto_quit", ) - ).intersection(set(kwargs.keys())): + ).intersection(set(kwargs.keys())) + if reserved_clash: raise RuntimeError( - "One or more of the EvMenu `**kwargs` is reserved by EvMenu for internal use." + f"One or more of the EvMenu `**kwargs` ({list(reserved_clash)}) is reserved by EvMenu for internal use." ) for key, val in kwargs.items(): setattr(self, key, val) From 78c7214a46c4427fbee04a1a87dbfb982fc732fb Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 11:19:10 +0200 Subject: [PATCH 08/34] Add experimental evmenu helper template, currently non-persistent --- evennia/contrib/gametutorial.py | 291 ++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 evennia/contrib/gametutorial.py diff --git a/evennia/contrib/gametutorial.py b/evennia/contrib/gametutorial.py new file mode 100644 index 0000000000..598fc3b11f --- /dev/null +++ b/evennia/contrib/gametutorial.py @@ -0,0 +1,291 @@ +""" +Game tutor + +Evennia contrib - Griatch 2020 + +This contrib is a system for easily adding a tutor/tutorial for your game +(something that should be considered a necessity for any game ...). + +It consists of a single room that will be created for each player/character +wanting to go through the tutorial. The text is presented as a menu of +self-sustained 'lessons' that the user can either jump freely between or step +through wizard-style. In each lesson, the tutor will track progress (for +example the user may be asked to try out a certain command, and the tutor will +not move on until that command has been tried). +:: + # node Start + + Neque ea alias perferendis molestiae eligendi. Debitis exercitationem + exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia + non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim + ratione. + + ## options + + 1: first option -> node1 + 2: second option -> node2 + 3: node3 -> gotonode3() + next;n: node2 + top: start + >input: return to go back -> start + >input foo*: foo() + >input bar*: bar() + + # node node1 + + Neque ea alias perferendis molestiae eligendi. Debitis exercitationem + exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia + non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim + ratione. + + ... + +""" + +import re + +from evennia import EvMenu +from fnmatch import fnmatch + +# support # NODE name, #NODE name ... +_RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) +_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) +_RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) + + +def gotofunc(caller, raw_string, **kwargs): + goto = kwargs['goto'] + callables = kwargs['callables'] + if _RE_CALLABLE.match(goto): + gotofunc = goto.strip()[:-2] + if gotofunc in callables: + return callables[gotofunc](caller, raw_string, **kwargs) + return goto + +def inputgotofunc(caller, raw_string, **kwargs): + gotomap = kwargs['gotomap'] + callables = kwargs['callables'] + + # start with glob patterns + for pattern, goto in gotomap.items(): + if fnmatch(raw_string.lower(), pattern): + if _RE_CALLABLE.match(goto): + gotofunc = goto.strip()[:-2] + if gotofunc in callables: + return callables[gotofunc](caller, raw_string, **kwargs) + return goto + # no glob pattern match; try regex + for pattern, goto in gotomap.items(): + if re.match(pattern, raw_string.lower(), flags=re.I + re.M): + if _RE_CALLABLE.match(goto): + gotofunc = goto.strip()[:-2] + if gotofunc in callables: + return callables[gotofunc](caller, raw_string, **kwargs) + return goto + # no match, rerun current node + return None + + +def generated_node(caller, raw_string, text="", options=None, + nodename="", **kwargs): + return text, options + + +class ParseMenuForm: + + def __init__(self, caller, formstr, callables=None): + self.caller = caller + self.formstr = formstr + self.callables = callables or {} + self.menutree = self.parse(formstr) + + def _generate_node(self, nodename, text, options): + """ + Generate a node from the parsed string + """ + def node(caller, raw_string, nodename=nodename, **kwargs): + return text, options + return node + + def _parse_options(self, optiontxt): + """ + Parse option section into option dict. + """ + options = [] + optiontxt = optiontxt[0].strip() if optiontxt else "" + optionlist = [optline.strip() for optline in optiontxt.split("\n")] + inputparsemap = {} + + for inum, optline in enumerate(optionlist): + if optline.startswith("#") or not ":" in optline: + # skip comments or invalid syntax + continue + key = "" + desc = "" + pattern = None + + key, goto = [part.strip() for part in optline.split(":", 1)] + + # desc -> goto + if "->" in goto: + desc, goto = [part.strip() for part in goto.split("->", 1)] + + # parse key [pattern] + key = [part.strip() for part in key.split(";")] + if not key: + # fall back to this being the Nth option + key = [f"{inum + 1}"] + main_key = key[0] + + if main_key.startswith(">input"): + key[0] = "_default" + pattern = main_key[6:].strip() + + if pattern is not None: + # if we have a pattern, build the arguments for _default later + inputparsemap[pattern] = goto + else: + # a regular goto string target + option = { + "key": key, + "goto": (gotofunc, { + "goto": goto, + "callables": self.callables}) + } + if desc: + option["desc"] = desc + options.append(option) + + if inputparsemap: + # if this exists we must create a _default entry too + options.append({ + "key": "_default", + "goto": (inputgotofunc, { + "gotomap": inputparsemap, + "callables": self.callables + }) + }) + + return options + + def parse(self, formstr): + """ + Parse the menu string format into a node tree. + """ + nodetree = {} + errors = [] + splits = _RE_NODE.split(formstr) + splits = splits[1:] if splits else [] + + # from evennia import set_trace;set_trace(term_size=(140,120)) + + for node_ind in range(0, len(splits), 2): + nodename, nodetxt = splits[node_ind], splits[node_ind + 1] + text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2) + options = self._parse_options(optiontxt) + nodetree[nodename] = self._generate_node(nodename, text, options) + + return nodetree + + +# class GameTutor(EvMenu): +# +# # tutorial helpers +# +# @staticmethod +# def nextprev(prevnode, nextnode, **kwargs): +# """ +# Add return to options to add a prev/next entry +# """ +# if kwargs: +# prevnode = (prevnode, kwargs) +# nextnode = (nextnode, kwargs) +# +# return ( +# {"key": ("|w[p]|nrev", "prev", "p"), +# "goto": prevnode}, +# {"key": ("|w[n]|next", "next", "n"), +# "goto": nextnode} +# ) + + +def test_generator(caller): + + MENU_DESC = \ + """ + # node start + + Neque ea alias perferendis molestiae eligendi. Debitis exercitationem + exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia + non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim + ratione. + + ## options + + 1: first option -> node1 + 2: second option -> node2 + 3: node3 -> gotonode3() + next;n: node2 + top: start + >input: return to go back -> start + >input foo*: foo() + >input bar*: bar() + + + # node node1 + + Neque ea alias perferendis molestiae eligendi. Debitis exercitationem + exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia + non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim + ratione. + + ## options + + back: start + to node 2: node2 + run foo (rerun node): foo() + + + # node node2 + + In node 2! + + ## options + + back: back to start -> start + + + # node bar + + In node bar! + + ## options + + back: back to start -> start + + """ + + def gotonode3(caller, raw_string, **kwargs): + print("in gotonode3", caller, raw_string, kwargs) + return None + + def foo(caller, raw_string, **kwargs): + print("in foo", caller, raw_string, kwargs) + return "node2" + + def bar(caller, raw_string, **kwargs): + print("in bar", caller, raw_string, kwargs) + return "bar" + + callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar} + + mform = ParseMenuForm(caller, MENU_DESC, callables) + + if isinstance(caller, str): + print(mform.menutree) + else: + EvMenu(caller, mform.menutree) + + +if __name__ == "__main__": + test_generator("<GriatchCaller>") From fad306b9321b603c4331d6a24e2e6683cae5d99d Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 17:53:53 +0200 Subject: [PATCH 09/34] Complete implementation of stand-alone menu-template parser --- evennia/contrib/gametutorial.py | 263 ++++++++++++++++++++------------ evennia/utils/evmenu.py | 12 +- 2 files changed, 174 insertions(+), 101 deletions(-) diff --git a/evennia/contrib/gametutorial.py b/evennia/contrib/gametutorial.py index 598fc3b11f..b88e2b2a77 100644 --- a/evennia/contrib/gametutorial.py +++ b/evennia/contrib/gametutorial.py @@ -43,71 +43,112 @@ not move on until that command has been tried). """ import re +from ast import literal_eval from evennia import EvMenu from fnmatch import fnmatch +# i18n +from django.utils.translation import gettext as _ -# support # NODE name, #NODE name ... _RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) _RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) _RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) +_RE_CALLABLE = re.compile( + r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I+re.M) + +_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.") + +_OPTION_INPUT_MARKER = ">" +_OPTION_ALIAS_MARKER = ";" +_OPTION_SEP_MARKER = ":" +_OPTION_CALL_MARKER = "->" +_OPTION_COMMENT_START = "#" -def gotofunc(caller, raw_string, **kwargs): +# Input/option/goto handler functions that allows for dynamically generated +# nodes read from the menu template. + +def _generated_goto_func(caller, raw_string, **kwargs): goto = kwargs['goto'] - callables = kwargs['callables'] + goto_callables = kwargs['goto_callables'] + current_nodename = kwargs['current_nodename'] + if _RE_CALLABLE.match(goto): gotofunc = goto.strip()[:-2] - if gotofunc in callables: - return callables[gotofunc](caller, raw_string, **kwargs) - return goto + if gotofunc in goto_callables: + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + caller.msg(_HELP_NO_OPTION_MATCH) + return goto, {"generated_nodename": goto} -def inputgotofunc(caller, raw_string, **kwargs): + +def _generated_input_goto_func(caller, raw_string, **kwargs): gotomap = kwargs['gotomap'] - callables = kwargs['callables'] + goto_callables = kwargs['goto_callables'] + current_nodename = kwargs['current_nodename'] # start with glob patterns for pattern, goto in gotomap.items(): if fnmatch(raw_string.lower(), pattern): - if _RE_CALLABLE.match(goto): - gotofunc = goto.strip()[:-2] - if gotofunc in callables: - return callables[gotofunc](caller, raw_string, **kwargs) - return goto + match = _RE_CALLABLE.match(goto) + print(f"goto {goto} -> match: {match}") + if match: + gotofunc = match.group("funcname") + gotokwargs = match.group("kwargs") or "" + print(f"gotofunc: {gotofunc}, {gotokwargs}") + if gotofunc in goto_callables: + for kwarg in gotokwargs.split(","): + if kwarg and "=" in kwarg: + print(f"kwarg {kwarg}") + key, value = [part.strip() for part in kwarg.split("=", 1)] + try: + key = literal_eval(key) + except ValueError: + pass + try: + value = literal_eval(value) + except ValueError: + pass + kwargs[key] = value + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + return goto, {"generated_nodename": goto} # no glob pattern match; try regex for pattern, goto in gotomap.items(): if re.match(pattern, raw_string.lower(), flags=re.I + re.M): if _RE_CALLABLE.match(goto): gotofunc = goto.strip()[:-2] - if gotofunc in callables: - return callables[gotofunc](caller, raw_string, **kwargs) - return goto + if gotofunc in goto_callables: + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + return goto, {"generated_nodename": goto} # no match, rerun current node - return None + caller.msg(_HELP_NO_OPTION_MATCH) + return None, {"generated_nodename": current_nodename} -def generated_node(caller, raw_string, text="", options=None, - nodename="", **kwargs): +def _generated_node(caller, raw_string, generated_nodename="", **kwargs): + text, options = caller.db._generated_menu_contents[generated_nodename] return text, options -class ParseMenuForm: +def parse_menu_template(caller, menu_template, goto_callables=None): + """ + Parse menu-template string - def __init__(self, caller, formstr, callables=None): - self.caller = caller - self.formstr = formstr - self.callables = callables or {} - self.menutree = self.parse(formstr) + Args: + caller (Object or Account): Entity using the menu. + menu_template (str): Menu described using the templating format. + goto_callables (dict, optional): Mapping between call-names and callables + on the form `callable(caller, raw_string, **kwargs)`. These are what is + available to use in the `menu_template` string. - def _generate_node(self, nodename, text, options): - """ - Generate a node from the parsed string - """ - def node(caller, raw_string, nodename=nodename, **kwargs): - return text, options - return node + """ - def _parse_options(self, optiontxt): + def _parse_options(nodename, optiontxt, goto_callables): """ Parse option section into option dict. """ @@ -117,40 +158,40 @@ class ParseMenuForm: inputparsemap = {} for inum, optline in enumerate(optionlist): - if optline.startswith("#") or not ":" in optline: + if (optline.startswith(_OPTION_COMMENT_START) + or _OPTION_SEP_MARKER not in optline): # skip comments or invalid syntax continue key = "" desc = "" pattern = None - key, goto = [part.strip() for part in optline.split(":", 1)] + key, goto = [part.strip() for part in optline.split(_OPTION_SEP_MARKER, 1)] # desc -> goto - if "->" in goto: - desc, goto = [part.strip() for part in goto.split("->", 1)] + if _OPTION_CALL_MARKER in goto: + desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)] - # parse key [pattern] - key = [part.strip() for part in key.split(";")] + # parse key [;aliases|pattern] + key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)] if not key: # fall back to this being the Nth option key = [f"{inum + 1}"] main_key = key[0] - if main_key.startswith(">input"): - key[0] = "_default" - pattern = main_key[6:].strip() - - if pattern is not None: + if main_key.startswith(_OPTION_INPUT_MARKER): # if we have a pattern, build the arguments for _default later + pattern = main_key[len(_OPTION_INPUT_MARKER):].strip() inputparsemap[pattern] = goto + print(f"registering input goto {pattern} -> {goto}") else: - # a regular goto string target + # a regular goto string/callable target option = { "key": key, - "goto": (gotofunc, { + "goto": (_generated_goto_func, { "goto": goto, - "callables": self.callables}) + "current_nodename": nodename, + "goto_callables": goto_callables}) } if desc: option["desc"] = desc @@ -160,58 +201,94 @@ class ParseMenuForm: # if this exists we must create a _default entry too options.append({ "key": "_default", - "goto": (inputgotofunc, { + "goto": (_generated_input_goto_func, { "gotomap": inputparsemap, - "callables": self.callables + "current_nodename": nodename, + "goto_callables": goto_callables }) }) return options - def parse(self, formstr): + def _parse(caller, menu_template, goto_callables): """ Parse the menu string format into a node tree. """ nodetree = {} - errors = [] - splits = _RE_NODE.split(formstr) + splits = _RE_NODE.split(menu_template) splits = splits[1:] if splits else [] # from evennia import set_trace;set_trace(term_size=(140,120)) - + content_map = {} for node_ind in range(0, len(splits), 2): nodename, nodetxt = splits[node_ind], splits[node_ind + 1] text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2) - options = self._parse_options(optiontxt) - nodetree[nodename] = self._generate_node(nodename, text, options) + options = _parse_options(nodename, optiontxt, goto_callables) + content_map[nodename] = (text, options) + nodetree[nodename] = _generated_node + caller.db._generated_menu_contents = content_map return nodetree + return _parse(caller, menu_template, goto_callables) -# class GameTutor(EvMenu): -# -# # tutorial helpers -# -# @staticmethod -# def nextprev(prevnode, nextnode, **kwargs): -# """ -# Add return to options to add a prev/next entry -# """ -# if kwargs: -# prevnode = (prevnode, kwargs) -# nextnode = (nextnode, kwargs) -# -# return ( -# {"key": ("|w[p]|nrev", "prev", "p"), -# "goto": prevnode}, -# {"key": ("|w[n]|next", "next", "n"), -# "goto": nextnode} -# ) + +def template2menu(caller, menu_template, goto_callables=None, + startnode="start", startnode_input=None, persistent=False, + **kwargs): + """ + Helper function to generate and start an EvMenu based on a menu template + string. + + Args: + caller (Object or Account): The entity using the menu. + menu_template (str): The menu-template string describing the content + and structure of the menu. It can also be the python-path to, or a module + containing a `MENU_TEMPLATE` global variable with the template. + goto_callables (dict, optional): Mapping of callable-names to + module-global objects to reference by name in the menu-template. + Must be on the form `callable(caller, raw_string, **kwargs)`. + startnode (str, optional): The name of the startnode, if not 'start'. + startnode_input (str or tuple, optional): If a string, the `raw_string` + arg to pass into the starting node. Otherwise should be on form + `(raw_string, {kwargs})`, where `raw_string` and `**kwargs` will be + passed into the start node. + persistent (bool, optional): If the generated menu should be persistent. + **kwargs: Other kwargs will be passed to EvMenu. + + + """ + goto_callables = goto_callables or {} + startnode_raw = "" + startnode_kwargs = {"generated_nodename": startnode} + if isinstance(startnode_input, str): + startnode_raw = startnode_input + elif isinstance(startnode_input, (tuple, list)): + startnode_raw = startnode_input[0] + startnode_kwargs.update(startnode_input[1]) + + menu_tree = parse_menu_template(caller, menu_template, goto_callables) + EvMenu(caller, menu_tree, + startnode_input=(startnode_raw, startnode_kwargs), + persistent=True, **kwargs) + + +def gotonode3(caller, raw_string, **kwargs): + print("in gotonode3", caller, raw_string, kwargs) + return None + +def foo(caller, raw_string, **kwargs): + print("in foo", caller, raw_string, kwargs) + return "node2" + +def bar(caller, raw_string, **kwargs): + print("in bar", caller, raw_string, kwargs) + return "bar" def test_generator(caller): - MENU_DESC = \ + MENU_TEMPLATE = \ """ # node start @@ -227,10 +304,11 @@ def test_generator(caller): 3: node3 -> gotonode3() next;n: node2 top: start - >input: return to go back -> start - >input foo*: foo() - >input bar*: bar() - + > foo*: foo() + > bar*: bar(a=4, boo=groo) + > [5,6]0+?: foo() + > great: node2 + > fail: bar() # node node1 @@ -244,7 +322,7 @@ def test_generator(caller): back: start to node 2: node2 run foo (rerun node): foo() - + >: return to go back -> start # node node2 @@ -262,29 +340,16 @@ def test_generator(caller): ## options back: back to start -> start + end: end + + # node end + + In node end! """ - def gotonode3(caller, raw_string, **kwargs): - print("in gotonode3", caller, raw_string, kwargs) - return None - - def foo(caller, raw_string, **kwargs): - print("in foo", caller, raw_string, kwargs) - return "node2" - - def bar(caller, raw_string, **kwargs): - print("in bar", caller, raw_string, kwargs) - return "bar" - callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar} - - mform = ParseMenuForm(caller, MENU_DESC, callables) - - if isinstance(caller, str): - print(mform.menutree) - else: - EvMenu(caller, mform.menutree) + template2menu(caller, MENU_TEMPLATE, callables) if __name__ == "__main__": diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index ea61ec11a8..08e08de8f2 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -190,7 +190,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT from django.utils.translation import gettext as _ _ERR_NOT_IMPLEMENTED = _( - "Menu node '{nodename}' is either not implemented or " "caused an error. Make another choice." + "Menu node '{nodename}' is either not implemented or caused an error. " + "Make another choice or try 'q' to abort." ) _ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_NO_OPTION_DESC = _("No description.") @@ -537,11 +538,18 @@ class EvMenu: menu_cmdset.priority = int(cmdset_priority) self.caller.cmdset.add(menu_cmdset, permanent=persistent) + reserved_startnode_kwargs = set(("nodename", "raw_string")) 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).") + clashing_kwargs = reserved_startnode_kwargs.intersection(set(startnode_kwargs.keys())) + if clashing_kwargs: + raise RuntimeError( + f"Evmenu startnode_inputs includes kwargs {tuple(clashing_kwargs)} that " + "clashes with EvMenu's internal usage.") + # start the menu self.goto(self._startnode, startnode_input, **startnode_kwargs) @@ -986,7 +994,7 @@ class EvMenu: """ cmd = strip_ansi(raw_string.strip().lower()) - if cmd in self.options: + if self.options and cmd in self.options: # this will take precedence over the default commands # below goto, goto_kwargs, execfunc, exec_kwargs = self.options[cmd] From 7e58fee1715ec22764d78a3ff28946729baeead6 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 17:55:08 +0200 Subject: [PATCH 10/34] Ran black on codes --- evennia/contrib/gametutorial.py | 82 +++++++++++++++++++++------------ evennia/utils/evmenu.py | 3 +- 2 files changed, 54 insertions(+), 31 deletions(-) diff --git a/evennia/contrib/gametutorial.py b/evennia/contrib/gametutorial.py index b88e2b2a77..ede1e10523 100644 --- a/evennia/contrib/gametutorial.py +++ b/evennia/contrib/gametutorial.py @@ -47,6 +47,7 @@ from ast import literal_eval from evennia import EvMenu from fnmatch import fnmatch + # i18n from django.utils.translation import gettext as _ @@ -54,7 +55,8 @@ _RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) _RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) _RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) _RE_CALLABLE = re.compile( - r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I+re.M) + r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I + re.M +) _HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.") @@ -68,10 +70,11 @@ _OPTION_COMMENT_START = "#" # Input/option/goto handler functions that allows for dynamically generated # nodes read from the menu template. + def _generated_goto_func(caller, raw_string, **kwargs): - goto = kwargs['goto'] - goto_callables = kwargs['goto_callables'] - current_nodename = kwargs['current_nodename'] + goto = kwargs["goto"] + goto_callables = kwargs["goto_callables"] + current_nodename = kwargs["current_nodename"] if _RE_CALLABLE.match(goto): gotofunc = goto.strip()[:-2] @@ -84,9 +87,9 @@ def _generated_goto_func(caller, raw_string, **kwargs): def _generated_input_goto_func(caller, raw_string, **kwargs): - gotomap = kwargs['gotomap'] - goto_callables = kwargs['goto_callables'] - current_nodename = kwargs['current_nodename'] + gotomap = kwargs["gotomap"] + goto_callables = kwargs["goto_callables"] + current_nodename = kwargs["current_nodename"] # start with glob patterns for pattern, goto in gotomap.items(): @@ -158,8 +161,7 @@ def parse_menu_template(caller, menu_template, goto_callables=None): inputparsemap = {} for inum, optline in enumerate(optionlist): - if (optline.startswith(_OPTION_COMMENT_START) - or _OPTION_SEP_MARKER not in optline): + if optline.startswith(_OPTION_COMMENT_START) or _OPTION_SEP_MARKER not in optline: # skip comments or invalid syntax continue key = "" @@ -181,17 +183,21 @@ def parse_menu_template(caller, menu_template, goto_callables=None): if main_key.startswith(_OPTION_INPUT_MARKER): # if we have a pattern, build the arguments for _default later - pattern = main_key[len(_OPTION_INPUT_MARKER):].strip() + pattern = main_key[len(_OPTION_INPUT_MARKER) :].strip() inputparsemap[pattern] = goto print(f"registering input goto {pattern} -> {goto}") else: # a regular goto string/callable target option = { "key": key, - "goto": (_generated_goto_func, { - "goto": goto, - "current_nodename": nodename, - "goto_callables": goto_callables}) + "goto": ( + _generated_goto_func, + { + "goto": goto, + "current_nodename": nodename, + "goto_callables": goto_callables, + }, + ), } if desc: option["desc"] = desc @@ -199,14 +205,19 @@ def parse_menu_template(caller, menu_template, goto_callables=None): if inputparsemap: # if this exists we must create a _default entry too - options.append({ - "key": "_default", - "goto": (_generated_input_goto_func, { - "gotomap": inputparsemap, - "current_nodename": nodename, - "goto_callables": goto_callables - }) - }) + options.append( + { + "key": "_default", + "goto": ( + _generated_input_goto_func, + { + "gotomap": inputparsemap, + "current_nodename": nodename, + "goto_callables": goto_callables, + }, + ), + } + ) return options @@ -233,9 +244,15 @@ def parse_menu_template(caller, menu_template, goto_callables=None): return _parse(caller, menu_template, goto_callables) -def template2menu(caller, menu_template, goto_callables=None, - startnode="start", startnode_input=None, persistent=False, - **kwargs): +def template2menu( + caller, + menu_template, + goto_callables=None, + startnode="start", + startnode_input=None, + persistent=False, + **kwargs, +): """ Helper function to generate and start an EvMenu based on a menu template string. @@ -268,19 +285,25 @@ def template2menu(caller, menu_template, goto_callables=None, startnode_kwargs.update(startnode_input[1]) menu_tree = parse_menu_template(caller, menu_template, goto_callables) - EvMenu(caller, menu_tree, - startnode_input=(startnode_raw, startnode_kwargs), - persistent=True, **kwargs) + EvMenu( + caller, + menu_tree, + startnode_input=(startnode_raw, startnode_kwargs), + persistent=True, + **kwargs, + ) def gotonode3(caller, raw_string, **kwargs): print("in gotonode3", caller, raw_string, kwargs) return None + def foo(caller, raw_string, **kwargs): print("in foo", caller, raw_string, kwargs) return "node2" + def bar(caller, raw_string, **kwargs): print("in bar", caller, raw_string, kwargs) return "bar" @@ -288,8 +311,7 @@ def bar(caller, raw_string, **kwargs): def test_generator(caller): - MENU_TEMPLATE = \ - """ + MENU_TEMPLATE = """ # node start Neque ea alias perferendis molestiae eligendi. Debitis exercitationem diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 08e08de8f2..b312637ecf 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -548,7 +548,8 @@ class EvMenu: if clashing_kwargs: raise RuntimeError( f"Evmenu startnode_inputs includes kwargs {tuple(clashing_kwargs)} that " - "clashes with EvMenu's internal usage.") + "clashes with EvMenu's internal usage." + ) # start the menu self.goto(self._startnode, startnode_input, **startnode_kwargs) From 300429a03f3e8de2bf8a7b8dfe0299c0f831e399 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 20:22:40 +0200 Subject: [PATCH 11/34] Start adding unit tests for menu templating --- evennia/contrib/gametutorial.py | 45 +- evennia/utils/evmenu.py | 496 +++++++++++++-------- evennia/utils/tests/data/evmenu_example.py | 221 +++++++++ evennia/utils/tests/test_evmenu.py | 67 ++- 4 files changed, 619 insertions(+), 210 deletions(-) create mode 100644 evennia/utils/tests/data/evmenu_example.py diff --git a/evennia/contrib/gametutorial.py b/evennia/contrib/gametutorial.py index ede1e10523..e2221aaad3 100644 --- a/evennia/contrib/gametutorial.py +++ b/evennia/contrib/gametutorial.py @@ -42,6 +42,7 @@ not move on until that command has been tried). """ +import sys import re from ast import literal_eval @@ -52,7 +53,7 @@ from fnmatch import fnmatch from django.utils.translation import gettext as _ _RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) -_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) +_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS*?\s*?$", re.I + re.M) _RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) _RE_CALLABLE = re.compile( r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I + re.M @@ -133,8 +134,8 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): return None, {"generated_nodename": current_nodename} -def _generated_node(caller, raw_string, generated_nodename="", **kwargs): - text, options = caller.db._generated_menu_contents[generated_nodename] +def _generated_node(caller, raw_string, **kwargs): + text, options = caller.db._generated_menu_contents[kwargs["_current_nodename"]] return text, options @@ -249,7 +250,6 @@ def template2menu( menu_template, goto_callables=None, startnode="start", - startnode_input=None, persistent=False, **kwargs, ): @@ -266,30 +266,17 @@ def template2menu( module-global objects to reference by name in the menu-template. Must be on the form `callable(caller, raw_string, **kwargs)`. startnode (str, optional): The name of the startnode, if not 'start'. - startnode_input (str or tuple, optional): If a string, the `raw_string` - arg to pass into the starting node. Otherwise should be on form - `(raw_string, {kwargs})`, where `raw_string` and `**kwargs` will be - passed into the start node. persistent (bool, optional): If the generated menu should be persistent. - **kwargs: Other kwargs will be passed to EvMenu. + **kwargs: All kwargs will be passed into EvMenu. """ goto_callables = goto_callables or {} - startnode_raw = "" - startnode_kwargs = {"generated_nodename": startnode} - if isinstance(startnode_input, str): - startnode_raw = startnode_input - elif isinstance(startnode_input, (tuple, list)): - startnode_raw = startnode_input[0] - startnode_kwargs.update(startnode_input[1]) - menu_tree = parse_menu_template(caller, menu_template, goto_callables) EvMenu( caller, menu_tree, - startnode_input=(startnode_raw, startnode_kwargs), - persistent=True, + persistent=persistent, **kwargs, ) @@ -309,6 +296,17 @@ def bar(caller, raw_string, **kwargs): return "bar" +def customcall(caller, raw_string, **kwargs): + return "start" + +def customnode(caller, raw_string, **kwargs): + text = "This is a custom node!" + options = { + "desc": "Go back", + "goto": customcall + } + return text, options + def test_generator(caller): MENU_TEMPLATE = """ @@ -344,6 +342,7 @@ def test_generator(caller): back: start to node 2: node2 run foo (rerun node): foo() + customnode: Go to custom node -> customnode >: return to go back -> start # node node2 @@ -371,7 +370,13 @@ def test_generator(caller): """ callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar} - template2menu(caller, MENU_TEMPLATE, callables) + dct = parse_menu_template(caller, MENU_TEMPLATE, callables) + dct["customnode"] = customnode + + EvMenu(caller, dct) + + + # template2menu(caller, MENU_TEMPLATE, callables) if __name__ == "__main__": diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index b312637ecf..9a9610acac 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -162,11 +162,114 @@ For a menu demo, import CmdTestMenu from this module and add it to your default cmdset. Run it with this module, like `testmenu evennia.utils.evmenu`. + +## Menu generation from template string + +In evmenu.py is a helper function `parse_menu_template` that parses a +template-string and outputs a menu-tree dictionary suitable to pass into +EvMenu: +:: + + menutree = evmenu.parse_menu_template(caller, menu_template, goto_callables) + EvMenu(caller, menutree) + +For maximum flexibility you can inject normally-created nodes in the menu tree +before passing it to EvMenu. If that's not needed, you can also create a menu +in one step with: +:: + + evmenu.template2menu(caller, menu_template, goto_callables) + +The `goto_callables` is a mapping `{"funcname": callable, ...}`, where each +callable must be a module-global function on the form +`funcname(caller, raw_string, **kwargs)` (like any goto-callable). The +`menu_template` is a multi-line string on the following form: +:: + + ## node start + + This is the text of the start node. + The text area can have multiple lines, line breaks etc. + + Each option below is one of these forms + key: desc -> gotostr_or_func + key: gotostr_or_func + >: gotostr_or_func + > glob/regex: gotostr_or_func + + ## options + + # comments are only allowed from beginning of line. + # Indenting is not necessary, but good for readability + + 1: Option number 1 -> node1 + 2: Option number 2 -> node2 + next: This steps next -> go_back() + # the -> can be ignored if there is no desc + back: go_back(from_node=start) + abort: abort + + ## node node1 + + Text for Node1. Enter a message! + <return> to go back. + + ## options + + # Starting the option-line with > + # allows to perform different actions depending on + # what is inserted. + + # this catches everything starting with foo + > foo*: handle_foo_message() + + # regex are also allowed (this catches number inputs) + > [0-9]+?: handle_numbers() + + # this catches the empty return + >: start + + # this catches everything else + > *: handle_message(from_node=node1) + + ## node node2 + + Text for Node2. Just go back. + + ## options + + >: start + + # node abort + + This exits the menu since there is no `## options` section. + +Each menu node is defined by a `# node <name>` containing the text of the node, +followed by `## options` Also `## NODE` and `## OPTIONS` work. No python code +logics is allowed in the template, this code is not evaluated but parsed. More +advanced dynamic usage requires a full node-function (which can be added to the +generated dict, as said). + +Adding `(..)` to a goto treats it as a callable and it must then be included in +the `goto_callable` mapping. Only named keywords (or no args at all) are +allowed, these will be added to the `**kwargs` going into the callable. Quoting +strings is only needed if wanting to pass strippable spaces, otherwise the +key:values will be converted to strings/numbers with literal_eval before passed +into the callable. + +The `> ` option takes a glob or regex to perform different actions depending on user +input. Make sure to sort these in increasing order of generality since they +will be tested in sequence. + + """ -import random +import re import inspect +from ast import literal_eval +from fnmatch import fnmatch + from inspect import isfunction, getargspec from django.conf import settings from evennia import Command, CmdSet @@ -176,6 +279,9 @@ from evennia.utils.ansi import strip_ansi from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop from evennia.commands import cmdhandler +# i18n +from django.utils.translation import gettext as _ + # read from protocol NAWS later? _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -186,8 +292,6 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # Return messages -# i18n -from django.utils.translation import gettext as _ _ERR_NOT_IMPLEMENTED = _( "Menu node '{nodename}' is either not implemented or caused an error. " @@ -668,6 +772,7 @@ class EvMenu: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) raise EvMenuError try: + kwargs["_current_nodename"] = nodename ret = self._safe_call(node, raw_string, **kwargs) if isinstance(ret, (tuple, list)) and len(ret) > 1: nodetext, options = ret[:2] @@ -1475,219 +1580,232 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): # ------------------------------------------------------------- # -# test menu strucure and testing command +# Menu generation from menu template string # # ------------------------------------------------------------- +_RE_NODE = re.compile(r"##\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) +_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) +_RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) +_RE_CALLABLE = re.compile( + r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I + re.M +) -def _generate_goto(caller, **kwargs): - return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"} +_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.") + +_OPTION_INPUT_MARKER = ">" +_OPTION_ALIAS_MARKER = ";" +_OPTION_SEP_MARKER = ":" +_OPTION_CALL_MARKER = "->" +_OPTION_COMMENT_START = "#" -def test_start_node(caller): - menu = caller.ndb._menutree - text = """ - This is an example menu. +# Input/option/goto handler functions that allows for dynamically generated +# nodes read from the menu template. - If you enter anything except the valid options, your input will be - recorded and you will be brought to a menu entry showing your - input. - Select options or use 'quit' to exit the menu. +def _generated_goto_func(caller, raw_string, **kwargs): + goto = kwargs["goto"] + goto_callables = kwargs["goto_callables"] + current_nodename = kwargs["current_nodename"] - The menu was initialized with two variables: %s and %s. - """ % ( - menu.testval, - menu.testval2, - ) + if _RE_CALLABLE.match(goto): + gotofunc = goto.strip()[:-2] + if gotofunc in goto_callables: + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + caller.msg(_HELP_NO_OPTION_MATCH) + return goto, {"generated_nodename": goto} - options = ( - { - "key": ("|yS|net", "s"), - "desc": "Set an attribute on yourself.", - "exec": lambda caller: caller.attributes.add("menuattrtest", "Test value"), - "goto": "test_set_node", - }, - { - "key": ("|yL|nook", "l"), - "desc": "Look and see a custom message.", - "goto": "test_look_node", - }, - {"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", - }, - {"key": "_default", "goto": "test_displayinput_node"}, - ) + +def _generated_input_goto_func(caller, raw_string, **kwargs): + gotomap = kwargs["gotomap"] + goto_callables = kwargs["goto_callables"] + current_nodename = kwargs["current_nodename"] + + # start with glob patterns + for pattern, goto in gotomap.items(): + if fnmatch(raw_string.lower(), pattern): + match = _RE_CALLABLE.match(goto) + if match: + gotofunc = match.group("funcname") + gotokwargs = match.group("kwargs") or "" + if gotofunc in goto_callables: + for kwarg in gotokwargs.split(","): + if kwarg and "=" in kwarg: + key, value = [part.strip() for part in kwarg.split("=", 1)] + try: + key = literal_eval(key) + except ValueError: + pass + try: + value = literal_eval(value) + except ValueError: + pass + kwargs[key] = value + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + return goto, {"generated_nodename": goto} + # no glob pattern match; try regex + for pattern, goto in gotomap.items(): + if re.match(pattern, raw_string.lower(), flags=re.I + re.M): + if _RE_CALLABLE.match(goto): + gotofunc = goto.strip()[:-2] + if gotofunc in goto_callables: + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + return goto, {"generated_nodename": goto} + # no match, rerun current node + caller.msg(_HELP_NO_OPTION_MATCH) + return None, {"generated_nodename": current_nodename} + + +def _generated_node(caller, raw_string, **kwargs): + text, options = caller.db._generated_menu_contents[kwargs["_current_nodename"]] return text, options -def test_look_node(caller): - text = "This is a custom look location!" - options = { - "key": ("|yL|nook", "l"), - "desc": "Go back to the previous menu.", - "goto": "test_start_node", - } - return text, options +def parse_menu_template(caller, menu_template, goto_callables=None): + """ + Parse menu-template string + Args: + caller (Object or Account): Entity using the menu. + menu_template (str): Menu described using the templating format. + goto_callables (dict, optional): Mapping between call-names and callables + on the form `callable(caller, raw_string, **kwargs)`. These are what is + available to use in the `menu_template` string. -def test_set_node(caller): - text = ( + """ + + def _parse_options(nodename, optiontxt, goto_callables): """ - The attribute 'menuattrtest' was set to - - |w%s|n - - (check it with examine after quitting the menu). - - This node's has only one option, and one of its key aliases is the - string "_default", meaning it will catch any input, in this case - to return to the main menu. So you can e.g. press <return> to go - back now. - """ - % caller.db.menuattrtest, # optional help text for this node + Parse option section into option dict. """ - This is the help entry for this node. It is created by returning - the node text as a tuple - the second string in that tuple will be - used as the help text. - """, - ) + options = [] + optiontxt = optiontxt[0].strip() if optiontxt else "" + optionlist = [optline.strip() for optline in optiontxt.split("\n")] + inputparsemap = {} - options = {"key": ("back (default)", "_default"), "goto": "test_start_node"} - return text, options + for inum, optline in enumerate(optionlist): + if optline.startswith(_OPTION_COMMENT_START) or _OPTION_SEP_MARKER not in optline: + # skip comments or invalid syntax + continue + key = "" + desc = "" + pattern = None + key, goto = [part.strip() for part in optline.split(_OPTION_SEP_MARKER, 1)] -def test_view_node(caller, **kwargs): - text = ( + # desc -> goto + if _OPTION_CALL_MARKER in goto: + desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)] + + # parse key [;aliases|pattern] + key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)] + if not key: + # fall back to this being the Nth option + key = [f"{inum + 1}"] + main_key = key[0] + + if main_key.startswith(_OPTION_INPUT_MARKER): + # if we have a pattern, build the arguments for _default later + pattern = main_key[len(_OPTION_INPUT_MARKER):].strip() + inputparsemap[pattern] = goto + else: + # a regular goto string/callable target + option = { + "key": key, + "goto": ( + _generated_goto_func, + { + "goto": goto, + "current_nodename": nodename, + "goto_callables": goto_callables, + }, + ), + } + if desc: + option["desc"] = desc + options.append(option) + + if inputparsemap: + # if this exists we must create a _default entry too + options.append( + { + "key": "_default", + "goto": ( + _generated_input_goto_func, + { + "gotomap": inputparsemap, + "current_nodename": nodename, + "goto_callables": goto_callables, + }, + ), + } + ) + + return options + + def _parse(caller, menu_template, goto_callables): """ - Your name is |g%s|n! - - click |lclook|lthere|le to trigger a look command under MXP. - This node's option has no explicit key (nor the "_default" key - set), and so gets assigned a number automatically. You can infact - -always- use numbers (1...N) to refer to listed options also if you - don't see a string option key (try it!). - """ - % caller.key - ) - 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): - text = ( + Parse the menu string format into a node tree. """ - You entered the text: + nodetree = {} + splits = _RE_NODE.split(menu_template) + splits = splits[1:] if splits else [] - "|w%s|n" + # from evennia import set_trace;set_trace(term_size=(140,120)) + content_map = {} + for node_ind in range(0, len(splits), 2): + nodename, nodetxt = splits[node_ind], splits[node_ind + 1] + text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2) + options = _parse_options(nodename, optiontxt, goto_callables) + content_map[nodename] = (text, options) + nodetree[nodename] = _generated_node + caller.db._generated_menu_contents = content_map - ... which could now be handled or stored here in some way if this - was not just an example. + return nodetree - This node has an option with a single alias "_default", which - 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. + return _parse(caller, menu_template, goto_callables) + + +def template2menu( + caller, + menu_template, + goto_callables=None, + startnode="start", + persistent=False, + **kwargs, +): """ - % raw_string.rstrip() - ) - options = {"key": "_default", "goto": "test_start_node"} - return text, options + Helper function to generate and start an EvMenu based on a menu template + string. + Args: + caller (Object or Account): The entity using the menu. + menu_template (str): The menu-template string describing the content + and structure of the menu. It can also be the python-path to, or a module + containing a `MENU_TEMPLATE` global variable with the template. + goto_callables (dict, optional): Mapping of callable-names to + module-global objects to reference by name in the menu-template. + Must be on the form `callable(caller, raw_string, **kwargs)`. + startnode (str, optional): The name of the startnode, if not 'start'. + persistent (bool, optional): If the generated menu should be persistent. + **kwargs: All kwargs will be passed into EvMenu. -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 - will exit here, followed by a call of the "look" command. - """ - return text, None - - -class CmdTestMenu(Command): - """ - Test menu - - Usage: - testmenu <menumodule> - - Starts a demo menu from a menu node definition module. + Returns: + EvMenu: The generated EvMenu. """ - - key = "testmenu" - - def func(self): - - if not self.args: - self.caller.msg("Usage: testmenu menumodule") - return - # start menu - EvMenu( - self.caller, - self.args.strip(), - startnode="test_start_node", - persistent=True, - cmdset_mergetype="Replace", - testval="val", - testval2="val2", - ) + goto_callables = goto_callables or {} + menu_tree = parse_menu_template(caller, menu_template, goto_callables) + return EvMenu( + caller, + menu_tree, + persistent=persistent, + **kwargs, + ) diff --git a/evennia/utils/tests/data/evmenu_example.py b/evennia/utils/tests/data/evmenu_example.py new file mode 100644 index 0000000000..4424e82c5b --- /dev/null +++ b/evennia/utils/tests/data/evmenu_example.py @@ -0,0 +1,221 @@ +# ------------------------------------------------------------- +# +# test menu strucure and testing command +# +# ------------------------------------------------------------- + +import random + + +def _generate_goto(caller, **kwargs): + return kwargs.get("name", "test_dynamic_node"), {"name": "replaced!"} + + +def test_start_node(caller): + menu = caller.ndb._menutree + text = """ + This is an example menu. + + If you enter anything except the valid options, your input will be + recorded and you will be brought to a menu entry showing your + input. + + Select options or use 'quit' to exit the menu. + + The menu was initialized with two variables: %s and %s. + """ % ( + menu.testval, + menu.testval2, + ) + + options = ( + { + "key": ("|yS|net", "s"), + "desc": "Set an attribute on yourself.", + "exec": lambda caller: caller.attributes.add("menuattrtest", "Test value"), + "goto": "test_set_node", + }, + { + "key": ("|yL|nook", "l"), + "desc": "Look and see a custom message.", + "goto": "test_look_node", + }, + {"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", + }, + {"key": "_default", "goto": "test_displayinput_node"}, + ) + return text, options + + +def test_look_node(caller): + text = "This is a custom look location!" + options = { + "key": ("|yL|nook", "l"), + "desc": "Go back to the previous menu.", + "goto": "test_start_node", + } + return text, options + + +def test_set_node(caller): + text = ( + """ + The attribute 'menuattrtest' was set to + + |w%s|n + + (check it with examine after quitting the menu). + + This node's has only one option, and one of its key aliases is the + string "_default", meaning it will catch any input, in this case + to return to the main menu. So you can e.g. press <return> to go + back now. + """ + % caller.db.menuattrtest, # optional help text for this node + """ + This is the help entry for this node. It is created by returning + the node text as a tuple - the second string in that tuple will be + used as the help text. + """, + ) + + options = {"key": ("back (default)", "_default"), "goto": "test_start_node"} + return text, options + + +def test_view_node(caller, **kwargs): + text = ( + """ + Your name is |g%s|n! + + click |lclook|lthere|le to trigger a look command under MXP. + This node's option has no explicit key (nor the "_default" key + set), and so gets assigned a number automatically. You can infact + -always- use numbers (1...N) to refer to listed options also if you + don't see a string option key (try it!). + """ + % caller.key + ) + 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): + text = ( + """ + You entered the text: + + "|w%s|n" + + ... which could now be handled or stored here in some way if this + was not just an example. + + This node has an option with a single alias "_default", which + 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.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 + will exit here, followed by a call of the "look" command. + """ + return text, None + + +# class CmdTestMenu(Command): +# """ +# Test menu +# +# Usage: +# testmenu <menumodule> +# +# Starts a demo menu from a menu node definition module. +# +# """ +# +# key = "testmenu" +# +# def func(self): +# +# if not self.args: +# self.caller.msg("Usage: testmenu menumodule") +# return +# # start menu +# EvMenu( +# self.caller, +# self.args.strip(), +# startnode="test_start_node", +# persistent=True, +# cmdset_mergetype="Replace", +# testval="val", +# testval2="val2", +# ) +# diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 128e857e9f..718ccacbab 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -18,7 +18,9 @@ To help debug the menu, turn on `debug_output`, which will print the traversal p """ import copy +from anything import Anything from django.test import TestCase +from evennia.utils.test_resources import EvenniaTest from evennia.utils import evmenu from evennia.utils import ansi from mock import MagicMock @@ -229,7 +231,7 @@ class TestEvMenu(TestCase): class TestEvMenuExample(TestEvMenu): - menutree = "evennia.utils.evmenu" + menutree = "evennia.utils.tests.data.evmenu_example" startnode = "test_start_node" kwargs = {"testval": "val", "testval2": "val2"} debug_output = False @@ -262,3 +264,66 @@ class TestEvMenuExample(TestEvMenu): def test_kwargsave(self): self.assertTrue(hasattr(self.menu, "testval")) self.assertTrue(hasattr(self.menu, "testval2")) + + +def _callnode1(caller, raw_string, **kwargs): + return "node1" + + +def _callnode2(caller, raw_string, **kwargs): + return "node2" + + +class TestMenuTemplateParse(EvenniaTest): + """Test menu templating helpers""" + + def setUp(self): + super().setUp() + + self.menu_template = """ + ## node start + + Neque ea alias perferendis molestiae eligendi. Debitis exercitationem + exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia + non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim + ratione. + + ## options + + 1: first option -> node1 + 2: second option -> node2 + next: node1 + + ## node node1 + + Node 1 + + ## options + + fwd: node2 + call1: callnode1() + call2: callnode2(foo=bar, bar=22, goo="another test") + >: start + + ## node node2 + + Text of node 2 + + ## options + + > foo*: node1 + > [0-9]+?: node2 + > back: start + + """ + self.goto_callables = {"callnode1": _callnode1, "callnode2": _callnode2} + + def test_parse_menu_template(self): + """EvMenu template testing""" + + menutree = evmenu.parse_menu_template(self.char1, self.menu_template, + self.goto_callables) + self.assertEqual(menutree, {"start": Anything, "node1": Anything, "node2": Anything}) + + def test_template2menu(self): + evmenu.template2menu(self.char1, self.menu_template, self.goto_callables) From b89d188c321988bc344c28c8fdf366e99ef8e8d3 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 27 Sep 2020 23:54:10 +0200 Subject: [PATCH 12/34] Start tutorialmenu --- evennia/contrib/gametutorial.py | 383 ------------------ .../contrib/tutorial_world/tutorialmenu.py | 174 ++++++++ evennia/utils/evmenu.py | 5 +- .../webclient/js/plugins/goldenlayout.js | 2 +- 4 files changed, 178 insertions(+), 386 deletions(-) delete mode 100644 evennia/contrib/gametutorial.py create mode 100644 evennia/contrib/tutorial_world/tutorialmenu.py diff --git a/evennia/contrib/gametutorial.py b/evennia/contrib/gametutorial.py deleted file mode 100644 index e2221aaad3..0000000000 --- a/evennia/contrib/gametutorial.py +++ /dev/null @@ -1,383 +0,0 @@ -""" -Game tutor - -Evennia contrib - Griatch 2020 - -This contrib is a system for easily adding a tutor/tutorial for your game -(something that should be considered a necessity for any game ...). - -It consists of a single room that will be created for each player/character -wanting to go through the tutorial. The text is presented as a menu of -self-sustained 'lessons' that the user can either jump freely between or step -through wizard-style. In each lesson, the tutor will track progress (for -example the user may be asked to try out a certain command, and the tutor will -not move on until that command has been tried). -:: - # node Start - - Neque ea alias perferendis molestiae eligendi. Debitis exercitationem - exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia - non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim - ratione. - - ## options - - 1: first option -> node1 - 2: second option -> node2 - 3: node3 -> gotonode3() - next;n: node2 - top: start - >input: return to go back -> start - >input foo*: foo() - >input bar*: bar() - - # node node1 - - Neque ea alias perferendis molestiae eligendi. Debitis exercitationem - exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia - non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim - ratione. - - ... - -""" - -import sys -import re -from ast import literal_eval - -from evennia import EvMenu -from fnmatch import fnmatch - -# i18n -from django.utils.translation import gettext as _ - -_RE_NODE = re.compile(r"#\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) -_RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS*?\s*?$", re.I + re.M) -_RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) -_RE_CALLABLE = re.compile( - r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I + re.M -) - -_HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.") - -_OPTION_INPUT_MARKER = ">" -_OPTION_ALIAS_MARKER = ";" -_OPTION_SEP_MARKER = ":" -_OPTION_CALL_MARKER = "->" -_OPTION_COMMENT_START = "#" - - -# Input/option/goto handler functions that allows for dynamically generated -# nodes read from the menu template. - - -def _generated_goto_func(caller, raw_string, **kwargs): - goto = kwargs["goto"] - goto_callables = kwargs["goto_callables"] - current_nodename = kwargs["current_nodename"] - - if _RE_CALLABLE.match(goto): - gotofunc = goto.strip()[:-2] - if gotofunc in goto_callables: - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - caller.msg(_HELP_NO_OPTION_MATCH) - return goto, {"generated_nodename": goto} - - -def _generated_input_goto_func(caller, raw_string, **kwargs): - gotomap = kwargs["gotomap"] - goto_callables = kwargs["goto_callables"] - current_nodename = kwargs["current_nodename"] - - # start with glob patterns - for pattern, goto in gotomap.items(): - if fnmatch(raw_string.lower(), pattern): - match = _RE_CALLABLE.match(goto) - print(f"goto {goto} -> match: {match}") - if match: - gotofunc = match.group("funcname") - gotokwargs = match.group("kwargs") or "" - print(f"gotofunc: {gotofunc}, {gotokwargs}") - if gotofunc in goto_callables: - for kwarg in gotokwargs.split(","): - if kwarg and "=" in kwarg: - print(f"kwarg {kwarg}") - key, value = [part.strip() for part in kwarg.split("=", 1)] - try: - key = literal_eval(key) - except ValueError: - pass - try: - value = literal_eval(value) - except ValueError: - pass - kwargs[key] = value - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - return goto, {"generated_nodename": goto} - # no glob pattern match; try regex - for pattern, goto in gotomap.items(): - if re.match(pattern, raw_string.lower(), flags=re.I + re.M): - if _RE_CALLABLE.match(goto): - gotofunc = goto.strip()[:-2] - if gotofunc in goto_callables: - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - return goto, {"generated_nodename": goto} - # no match, rerun current node - caller.msg(_HELP_NO_OPTION_MATCH) - return None, {"generated_nodename": current_nodename} - - -def _generated_node(caller, raw_string, **kwargs): - text, options = caller.db._generated_menu_contents[kwargs["_current_nodename"]] - return text, options - - -def parse_menu_template(caller, menu_template, goto_callables=None): - """ - Parse menu-template string - - Args: - caller (Object or Account): Entity using the menu. - menu_template (str): Menu described using the templating format. - goto_callables (dict, optional): Mapping between call-names and callables - on the form `callable(caller, raw_string, **kwargs)`. These are what is - available to use in the `menu_template` string. - - """ - - def _parse_options(nodename, optiontxt, goto_callables): - """ - Parse option section into option dict. - """ - options = [] - optiontxt = optiontxt[0].strip() if optiontxt else "" - optionlist = [optline.strip() for optline in optiontxt.split("\n")] - inputparsemap = {} - - for inum, optline in enumerate(optionlist): - if optline.startswith(_OPTION_COMMENT_START) or _OPTION_SEP_MARKER not in optline: - # skip comments or invalid syntax - continue - key = "" - desc = "" - pattern = None - - key, goto = [part.strip() for part in optline.split(_OPTION_SEP_MARKER, 1)] - - # desc -> goto - if _OPTION_CALL_MARKER in goto: - desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)] - - # parse key [;aliases|pattern] - key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)] - if not key: - # fall back to this being the Nth option - key = [f"{inum + 1}"] - main_key = key[0] - - if main_key.startswith(_OPTION_INPUT_MARKER): - # if we have a pattern, build the arguments for _default later - pattern = main_key[len(_OPTION_INPUT_MARKER) :].strip() - inputparsemap[pattern] = goto - print(f"registering input goto {pattern} -> {goto}") - else: - # a regular goto string/callable target - option = { - "key": key, - "goto": ( - _generated_goto_func, - { - "goto": goto, - "current_nodename": nodename, - "goto_callables": goto_callables, - }, - ), - } - if desc: - option["desc"] = desc - options.append(option) - - if inputparsemap: - # if this exists we must create a _default entry too - options.append( - { - "key": "_default", - "goto": ( - _generated_input_goto_func, - { - "gotomap": inputparsemap, - "current_nodename": nodename, - "goto_callables": goto_callables, - }, - ), - } - ) - - return options - - def _parse(caller, menu_template, goto_callables): - """ - Parse the menu string format into a node tree. - """ - nodetree = {} - splits = _RE_NODE.split(menu_template) - splits = splits[1:] if splits else [] - - # from evennia import set_trace;set_trace(term_size=(140,120)) - content_map = {} - for node_ind in range(0, len(splits), 2): - nodename, nodetxt = splits[node_ind], splits[node_ind + 1] - text, *optiontxt = _RE_OPTIONS_SEP.split(nodetxt, maxsplit=2) - options = _parse_options(nodename, optiontxt, goto_callables) - content_map[nodename] = (text, options) - nodetree[nodename] = _generated_node - caller.db._generated_menu_contents = content_map - - return nodetree - - return _parse(caller, menu_template, goto_callables) - - -def template2menu( - caller, - menu_template, - goto_callables=None, - startnode="start", - persistent=False, - **kwargs, -): - """ - Helper function to generate and start an EvMenu based on a menu template - string. - - Args: - caller (Object or Account): The entity using the menu. - menu_template (str): The menu-template string describing the content - and structure of the menu. It can also be the python-path to, or a module - containing a `MENU_TEMPLATE` global variable with the template. - goto_callables (dict, optional): Mapping of callable-names to - module-global objects to reference by name in the menu-template. - Must be on the form `callable(caller, raw_string, **kwargs)`. - startnode (str, optional): The name of the startnode, if not 'start'. - persistent (bool, optional): If the generated menu should be persistent. - **kwargs: All kwargs will be passed into EvMenu. - - - """ - goto_callables = goto_callables or {} - menu_tree = parse_menu_template(caller, menu_template, goto_callables) - EvMenu( - caller, - menu_tree, - persistent=persistent, - **kwargs, - ) - - -def gotonode3(caller, raw_string, **kwargs): - print("in gotonode3", caller, raw_string, kwargs) - return None - - -def foo(caller, raw_string, **kwargs): - print("in foo", caller, raw_string, kwargs) - return "node2" - - -def bar(caller, raw_string, **kwargs): - print("in bar", caller, raw_string, kwargs) - return "bar" - - -def customcall(caller, raw_string, **kwargs): - return "start" - -def customnode(caller, raw_string, **kwargs): - text = "This is a custom node!" - options = { - "desc": "Go back", - "goto": customcall - } - return text, options - -def test_generator(caller): - - MENU_TEMPLATE = """ - # node start - - Neque ea alias perferendis molestiae eligendi. Debitis exercitationem - exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia - non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim - ratione. - - ## options - - 1: first option -> node1 - 2: second option -> node2 - 3: node3 -> gotonode3() - next;n: node2 - top: start - > foo*: foo() - > bar*: bar(a=4, boo=groo) - > [5,6]0+?: foo() - > great: node2 - > fail: bar() - - # node node1 - - Neque ea alias perferendis molestiae eligendi. Debitis exercitationem - exercitationem quas blanditiis quisquam officia ut. Fugit aut fugit enim quia - non. Earum et excepturi animi ex esse accusantium et. Id adipisci eos enim - ratione. - - ## options - - back: start - to node 2: node2 - run foo (rerun node): foo() - customnode: Go to custom node -> customnode - >: return to go back -> start - - # node node2 - - In node 2! - - ## options - - back: back to start -> start - - - # node bar - - In node bar! - - ## options - - back: back to start -> start - end: end - - # node end - - In node end! - - """ - - callables = {"gotonode3": gotonode3, "foo": foo, "bar": bar} - dct = parse_menu_template(caller, MENU_TEMPLATE, callables) - dct["customnode"] = customnode - - EvMenu(caller, dct) - - - # template2menu(caller, MENU_TEMPLATE, callables) - - -if __name__ == "__main__": - test_generator("<GriatchCaller>") diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/tutorialmenu.py new file mode 100644 index 0000000000..4504770bc7 --- /dev/null +++ b/evennia/contrib/tutorial_world/tutorialmenu.py @@ -0,0 +1,174 @@ +""" +Game tutor + +Evennia contrib - Griatch 2020 + +This contrib is a tutorial menu using the EvMenu menu-templating system. + +""" + +from evennia.utils.evmenu import parse_menu_template, EvMenu + +# goto callables + +def command_passthrough(caller, raw_string, **kwargs): + cmd = kwargs.get("cmd") + on_success = kwargs.get('on_success') + if cmd: + caller.execute_cmd(cmd) + else: + caller.execute_cmd(raw_string) + return on_success + +def do_nothing(caller, raw_string, **kwargs): + return None + +def send_testing_tagged(caller, raw_string, **kwargs): + caller.msg(("This is a message tagged with 'testing' and " + "should appear in the pane you selected!\n " + f"You wrote: '{raw_string}'", {"type": "testing"})) + return None + +def send_string(caller, raw_string, **kwargs): + caller.msg(raw_string) + return None + + +MENU_TEMPLATE = """ + +## NODE start + +Welcome to |cEvennia|n! From this menu you can learn some more about the system and +also the basics of how to play a text-based game. You can exit this menu at +any time by using "q" or "quit". + +Select an option you want to learn more about below. + +## OPTIONS + + 1: About evennia -> about_evennia + 2: What is a MUD/MU*? -> about_muds + 3: Using the webclient -> using webclient + 4: Command input -> command_input + +# --------------------------------------------------------------------------------- + +## NODE about_evennia + +Evennia is a game engine for creating multiplayer online text-games. + +## OPTIONS + + back: start + next: about MUDs -> about_muds + >: about_muds + +# --------------------------------------------------------------------------------- + +## NODE about_muds + +The term MUD stands for Multi-user-Dungeon or -Dimension. These are the precursor +to graphical MMORPG-style games like World of Warcraft. + + +## OPTIONS + + back: about_evennia + next: using the webclient -> using webclient + back to top: start + >: using webclient + +# --------------------------------------------------------------------------------- + +## NODE using webclient + +Evennia supports traditional telnet clients but also offers a HTML5 web client. It +is found (on a default install) by pointing your web browser to + |yhttp:localhost:4001/webclient|n +For a live example, the public Evennia demo can be found at + |yhttps://demo.evennia.com/webclient|n + +The web client start out having two panes. The bottom one is where you insert commands +and the top one is where you see returns from the server. + +- Use |y<Return>|n (or click the arrow on the right) to send your input. +- Use |yCtrl + <up-arrow>|n to step back and repeat a command you entered previously. +- Use |yCtrl + <Return>|n to add a new line to your input without sending. + +If you want there is some |wextra|n info to learn about customizing the webclient. + +## OPTIONS + + back: about_muds + extra: learn more about customizing the webclient -> customizing the webclient + next: general command input -> command_input + back to top: start + >: back + +# --------------------------------------------------------------------------------- + +## NODE customizing the webclient + +|y1)|n The panes of the webclient can be resized and you can create additional panes. + +- Press the little plus (|w+|n) sign in the top left and a new tab will appear. +- Click and drag the tab and pull it far to the right and release when it creates two + panes next to each other. + +|y2)|n You can have certain server output only appear in certain panes. + +- In your new rightmost pane, click the diamond (⯁) symbol at the top. +- Unselect everything and make sure to select "testing". +- Click the diamond again so the menu closes. +- Next, write "|ytest Hello world!|n". A test-text should appear in your rightmost pane! + +|y3)|n You can customize general webclient settings by pressing the cogwheel in the upper +left corner. It allows to change things like font and if the client should play sound. + +The "message routing" allows for rerouting text matching a certain regular expression (regex) +to a web client pane with a specific tag that you set yourself. + +|y4)|n Close the right-hand pane with the |wX|n in the rop right corner. + +## OPTIONS + + back: using webclient + next: general command input -> command_input + back to top: start + > test *: send tagged message to new pane -> send_testing_tagged() + +# --------------------------------------------------------------------------------- + +## NODE command_input + +The first thing to learn is to use the |yhelp|n command. + +## OPTIONS + + back: using webclient + next: (end) -> end + back to top: start + > h|help: command_passthrough(cmd=help) + +# --------------------------------------------------------------------------------- + +## NODE end + +Thankyou for going through the tutorial! + + +""" + + +GOTO_CALLABLES = { + "command_passthrough": command_passthrough, + "send_testing_tagged": send_testing_tagged, + "do_nothing": do_nothing, + "send_string": send_string, +} + +def testmenu(caller): + menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) + # we'll use a custom EvMenu child later + EvMenu(caller, menutree, auto_help=False) + diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 9a9610acac..fd8dac3c80 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1584,7 +1584,7 @@ def get_input(caller, prompt, callback, session=None, *args, **kwargs): # # ------------------------------------------------------------- -_RE_NODE = re.compile(r"##\s*?NODE\s+?(?P<nodename>\S+?)$", re.I + re.M) +_RE_NODE = re.compile(r"##\s*?NODE\s+?(?P<nodename>\S[\S\s]*?)$", re.I + re.M) _RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) _RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) _RE_CALLABLE = re.compile( @@ -1650,7 +1650,7 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): return goto, {"generated_nodename": goto} # no glob pattern match; try regex for pattern, goto in gotomap.items(): - if re.match(pattern, raw_string.lower(), flags=re.I + re.M): + if pattern and re.match(pattern, raw_string.lower(), flags=re.I + re.M): if _RE_CALLABLE.match(goto): gotofunc = goto.strip()[:-2] if gotofunc in goto_callables: @@ -1748,6 +1748,7 @@ def parse_menu_template(caller, menu_template, goto_callables=None): } ) + print(f"nodename: {nodename}, options: {options}") return options def _parse(caller, menu_template, goto_callables): diff --git a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js index afc36c9aa0..36862272aa 100644 --- a/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js +++ b/evennia/web/webclient/static/webclient/js/plugins/goldenlayout.js @@ -6,7 +6,7 @@ let goldenlayout = (function () { var myLayout; - var knownTypes = ["all", "untagged"]; + var knownTypes = ["all", "untagged", "testing"]; var untagged = []; var newTabConfig = { From 84e26566aec62d23dad6f2bcf837b82ed04a2c45 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Tue, 29 Sep 2020 19:27:33 +0200 Subject: [PATCH 13/34] More work on evmenu helper --- .../contrib/tutorial_world/tutorialmenu.py | 3 +- evennia/utils/evmenu.py | 131 ++++++++++-------- 2 files changed, 73 insertions(+), 61 deletions(-) diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/tutorialmenu.py index 4504770bc7..07f3f26baf 100644 --- a/evennia/contrib/tutorial_world/tutorialmenu.py +++ b/evennia/contrib/tutorial_world/tutorialmenu.py @@ -170,5 +170,4 @@ GOTO_CALLABLES = { def testmenu(caller): menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) # we'll use a custom EvMenu child later - EvMenu(caller, menutree, auto_help=False) - + EvMenu(caller, menutree) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index fd8dac3c80..ca803d46b6 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -329,6 +329,19 @@ class EvMenuError(RuntimeError): pass +class EvMenuGotoAbortMessage(RuntimeError): + """ + This can be raised by a goto-callable to abort the goto flow. The message + stored with the executable will be sent to the caller who will remain on + the current node. This can be used to pass single-line returns without + re-running the entire node with text and options. + + Example: + raise EvMenuGotoMessage("That makes no sense.") + + """ + + # ------------------------------------------------------------- # # Menu command and command set @@ -795,6 +808,8 @@ class EvMenu: def run_exec(self, nodename, raw_string, **kwargs): """ + NOTE: This is deprecated. Use `goto` directly instead. + Run a function or node as a callback (with the 'exec' option key). Args: @@ -1100,24 +1115,29 @@ class EvMenu: """ cmd = strip_ansi(raw_string.strip().lower()) - if self.options and cmd in self.options: - # this will take precedence over the default commands - # below - 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"): - self.display_helptext() - elif self.auto_quit and cmd in ("quit", "q", "exit"): - self.close_menu() - elif self.debug_mode and cmd.startswith("menudebug"): - self.print_debug_info(cmd[9:].strip()) - elif self.default: - 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) + try: + if self.options and cmd in self.options: + # this will take precedence over the default commands + # below + 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"): + self.display_helptext() + elif self.auto_quit and cmd in ("quit", "q", "exit"): + self.close_menu() + elif self.debug_mode and cmd.startswith("menudebug"): + self.print_debug_info(cmd[9:].strip()) + elif self.default: + 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) + except EvMenuGotoAbortMessage as err: + # custom interrupt from inside a goto callable - print the message and + # stay on the current node. + self.caller.msg(str(err), session=self._session) def display_nodetext(self): self.caller.msg(self.nodetext, session=self._session) @@ -1603,20 +1623,37 @@ _OPTION_COMMENT_START = "#" # Input/option/goto handler functions that allows for dynamically generated # nodes read from the menu template. +def _process_callable(caller, goto, goto_callables, raw_string, + current_nodename, kwargs): + match = _RE_CALLABLE.match(goto) + if match: + gotofunc = match.group("funcname") + gotokwargs = match.group("kwargs") or "" + if gotofunc in goto_callables: + for kwarg in gotokwargs.split(","): + if kwarg and "=" in kwarg: + key, value = [part.strip() for part in kwarg.split("=", 1)] + try: + key = literal_eval(key) + except ValueError: + pass + try: + value = literal_eval(value) + except ValueError: + pass + kwargs[key] = value + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + if goto is None: + return goto, {"generated_nodename": current_nodename} + return goto, {"generated_nodename": goto} + def _generated_goto_func(caller, raw_string, **kwargs): goto = kwargs["goto"] goto_callables = kwargs["goto_callables"] current_nodename = kwargs["current_nodename"] - - if _RE_CALLABLE.match(goto): - gotofunc = goto.strip()[:-2] - if gotofunc in goto_callables: - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - caller.msg(_HELP_NO_OPTION_MATCH) - return goto, {"generated_nodename": goto} + return _process_callable(caller, goto, goto_callables, raw_string, + current_nodename, kwargs) def _generated_input_goto_func(caller, raw_string, **kwargs): @@ -1627,40 +1664,16 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): # start with glob patterns for pattern, goto in gotomap.items(): if fnmatch(raw_string.lower(), pattern): - match = _RE_CALLABLE.match(goto) - if match: - gotofunc = match.group("funcname") - gotokwargs = match.group("kwargs") or "" - if gotofunc in goto_callables: - for kwarg in gotokwargs.split(","): - if kwarg and "=" in kwarg: - key, value = [part.strip() for part in kwarg.split("=", 1)] - try: - key = literal_eval(key) - except ValueError: - pass - try: - value = literal_eval(value) - except ValueError: - pass - kwargs[key] = value - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - return goto, {"generated_nodename": goto} + return _process_callable(caller, goto, goto_callables, raw_string, + current_nodename, kwargs) # no glob pattern match; try regex for pattern, goto in gotomap.items(): if pattern and re.match(pattern, raw_string.lower(), flags=re.I + re.M): - if _RE_CALLABLE.match(goto): - gotofunc = goto.strip()[:-2] - if gotofunc in goto_callables: - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) - if goto is None: - return goto, {"generated_nodename": current_nodename} - return goto, {"generated_nodename": goto} - # no match, rerun current node - caller.msg(_HELP_NO_OPTION_MATCH) - return None, {"generated_nodename": current_nodename} + return _process_callable(caller, goto, goto_callables, raw_string, + current_nodename, kwargs) + # no match, show error + + raise EvMenuGotoAbortMessage(_HELP_NO_OPTION_MATCH) def _generated_node(caller, raw_string, **kwargs): @@ -1715,6 +1728,7 @@ def parse_menu_template(caller, menu_template, goto_callables=None): # if we have a pattern, build the arguments for _default later pattern = main_key[len(_OPTION_INPUT_MARKER):].strip() inputparsemap[pattern] = goto + print(f"main_key {main_key} {pattern} {goto}") else: # a regular goto string/callable target option = { @@ -1748,7 +1762,6 @@ def parse_menu_template(caller, menu_template, goto_callables=None): } ) - print(f"nodename: {nodename}, options: {options}") return options def _parse(caller, menu_template, goto_callables): From b28aefd36f914feae7661d3fdcb714caf9b9e91a Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Thu, 1 Oct 2020 23:49:04 +0200 Subject: [PATCH 14/34] Add cmdsets to show off cmds in tutorial menu --- evennia/commands/command.py | 3 + evennia/commands/default/help.py | 20 +- .../contrib/tutorial_world/tutorialmenu.py | 260 +++++++++++++++--- evennia/utils/evmenu.py | 8 +- 4 files changed, 243 insertions(+), 48 deletions(-) diff --git a/evennia/commands/command.py b/evennia/commands/command.py index dba035b481..c5b631057b 100644 --- a/evennia/commands/command.py +++ b/evennia/commands/command.py @@ -138,6 +138,9 @@ class Command(object, metaclass=CommandMeta): arg_regex - (optional) raw string regex defining how the argument part of the command should look in order to match for this command (e.g. must it be a space between cmdname and arg?) + auto_help_display_key - (optional) if given, this replaces the string shown + in the auto-help listing. This is particularly useful for system-commands + whose actual key is not really meaningful. (Note that if auto_help is on, this initial string is also used by the system to create the help entry for the command, so it's a good idea to diff --git a/evennia/commands/default/help.py b/evennia/commands/default/help.py index 9e1b45c2c6..5bee4647f3 100644 --- a/evennia/commands/default/help.py +++ b/evennia/commands/default/help.py @@ -219,10 +219,12 @@ class CmdHelp(Command): hdict_topic = defaultdict(list) # create the dictionaries {category:[topic, topic ...]} required by format_help_list # Filter commands that should be reached by the help - # system, but not be displayed in the table. + # system, but not be displayed in the table, or be displayed differently. for cmd in all_cmds: if self.should_list_cmd(cmd, caller): - hdict_cmd[cmd.help_category].append(cmd.key) + key = (cmd.auto_help_display_key + if hasattr(cmd, "auto_help_display_key") else cmd.key) + hdict_cmd[cmd.help_category].append(key) [hdict_topic[topic.help_category].append(topic.key) for topic in all_topics] # report back self.msg_help(self.format_help_list(hdict_cmd, hdict_topic)) @@ -266,10 +268,12 @@ class CmdHelp(Command): ] if len(match) == 1: + cmd = match[0] + key = cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key formatted = self.format_help_entry( - match[0].key, - match[0].get_help(caller, cmdset), - aliases=match[0].aliases, + key, + cmd.get_help(caller, cmdset), + aliases=cmd.aliases, suggested=suggestions, ) self.msg_help(formatted) @@ -290,8 +294,10 @@ class CmdHelp(Command): # try to see if a category name was entered if query in all_categories: self.msg_help( - self.format_help_list( - {query: [cmd.key for cmd in all_cmds if cmd.help_category == query]}, + self.format_help_list({ + query: [ + cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key + for cmd in all_cmds if cmd.help_category == query]}, {query: [topic.key for topic in all_topics if topic.help_category == query]}, ) ) diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/tutorialmenu.py index 07f3f26baf..dd05e3fc19 100644 --- a/evennia/contrib/tutorial_world/tutorialmenu.py +++ b/evennia/contrib/tutorial_world/tutorialmenu.py @@ -7,18 +7,12 @@ This contrib is a tutorial menu using the EvMenu menu-templating system. """ +from evennia import create_object +from evennia import CmdSet from evennia.utils.evmenu import parse_menu_template, EvMenu # goto callables -def command_passthrough(caller, raw_string, **kwargs): - cmd = kwargs.get("cmd") - on_success = kwargs.get('on_success') - if cmd: - caller.execute_cmd(cmd) - else: - caller.execute_cmd(raw_string) - return on_success def do_nothing(caller, raw_string, **kwargs): return None @@ -34,13 +28,123 @@ def send_string(caller, raw_string, **kwargs): return None +# resources for the look-demo + +_ROOM_DESC = """ +This is a small and comfortable wood cabin. Bright sunlight is shining in +through the windows. + +Use |ylook box|n or |yl box|n to examine the box in this room. +""" + +_BOX_DESC = """ +The box is made of wood. On it, letters are engraved, reading: + + Good! Now try '|ylook small|n'. + + You'll get an error! There are two things that 'small' could refer to here + - the 'small box' or the 'small, cozy cabin' itself. You will get a list of the + possibilities. + + You could either tell Evennia which one you wanted by picking a unique part + of their name (like '|ylook cozy|n') or use the number in the list to pick + the one you want, like this: + + |ylook 2-small|n + + As long as what you write is uniquely identifying you can be lazy and not + write the full name of the thing you want to look at. Try '|ylook bo|n', + '|yl co|n' or '|yl 1-sm|n'! + + ... Oh, and if you see database-ids like (#1245) by the name of objects, + it's because you are playing with Builder-privileges or higher. Regular + players will not see the numbers. + + Write |ynext|n to leave the cabin and continue with the tutorial. +""" + +def _maintain_demo_room(caller, delete=False): + """ + Handle the creation/cleanup of demo assets. We store them + on the character and clean them when leaving the menu later. + """ + # this is a tuple (room, obj) + roomdata = caller.db.tutorial_world_demo_room_data + + if delete: + if roomdata: + prev_loc, room, obj = roomdata + caller.location = prev_loc + obj.delete() + room.delete() + del caller.db.tutorial_world_demo_room_data + elif not roomdata: + room = create_object("evennia.objects.objects.DefaultRoom", + key="A small, cozy cabin") + room.db.desc = _ROOM_DESC.strip() + obj = create_object("evennia.objects.objects.DefaultObject", + key="A small wooden box") + obj.db.desc = _BOX_DESC.strip() + obj.location = room + # move caller into room and store + caller.db.tutorial_world_demo_room_data = (caller.location, room, obj) + caller.location = room + +class DemoCommandSet1(CmdSet): + """ + Demo the `look` command. + """ + key = "cmd_demo_cmdset_1" + priority = 2 + + def at_cmdset_creation(self): + from evennia import default_cmds + self.add(default_cmds.CmdLook()) + +def goto_command_demo_1(caller, raw_string, **kwargs): + """Generate a little room environment for testing out some commands.""" + _maintain_demo_room(caller) + caller.cmdset.add(DemoCommandSet1) # TODO - make persistent + return "command_demo_1" + +# resources for the general command demo + +class DemoCommandSet2(CmdSet): + """ + Demo other commands. + """ + key = "cmd_demo_cmdset_2" + priority = 2 + + def at_cmdset_creation(self): + from evennia import default_cmds + self.add(default_cmds.CmdHelp()) + + +def goto_command_demo_2(caller, raw_string, **kwargs): + _maintain_demo_room(caller, delete=True) + caller.cmdset.remove(DemoCommandSet1) + caller.cmdset.add(DemoCommandSet2) # TODO - make persistent + return "command_demo_2" + + +def command_passthrough(caller, raw_string, **kwargs): + cmd = kwargs.get("cmd") + on_success = kwargs.get('on_success') + if cmd: + caller.execute_cmd(cmd) + else: + caller.execute_cmd(raw_string) + return on_success + + MENU_TEMPLATE = """ ## NODE start Welcome to |cEvennia|n! From this menu you can learn some more about the system and -also the basics of how to play a text-based game. You can exit this menu at -any time by using "q" or "quit". +also the basics of how to play a text-based game. You can exit this menu at +any time by using "q" or "quit". Select an option you want to learn more about below. @@ -49,7 +153,7 @@ Select an option you want to learn more about below. 1: About evennia -> about_evennia 2: What is a MUD/MU*? -> about_muds 3: Using the webclient -> using webclient - 4: Command input -> command_input + 4: Using commands -> goto_command_demo_1() # --------------------------------------------------------------------------------- @@ -57,7 +161,7 @@ Select an option you want to learn more about below. Evennia is a game engine for creating multiplayer online text-games. -## OPTIONS +## OPTIONS back: start next: about MUDs -> about_muds @@ -67,41 +171,45 @@ Evennia is a game engine for creating multiplayer online text-games. ## NODE about_muds -The term MUD stands for Multi-user-Dungeon or -Dimension. These are the precursor +The term MUD stands for Multi-user-Dungeon or -Dimension. These are the precursor to graphical MMORPG-style games like World of Warcraft. ## OPTIONS - + back: about_evennia next: using the webclient -> using webclient - back to top: start + back to top: start >: using webclient # --------------------------------------------------------------------------------- ## NODE using webclient -Evennia supports traditional telnet clients but also offers a HTML5 web client. It -is found (on a default install) by pointing your web browser to - |yhttp:localhost:4001/webclient|n +|rNote: This is only relevant if you use Evennia's HTML5 web client. If you use a +third-party (telnet) mud-client, you can skip this section.|n + +Evennia's web client is (when you install the server locally) found by pointing +your web browser to + |yhttp://localhost:4001/webclient|n For a live example, the public Evennia demo can be found at |yhttps://demo.evennia.com/webclient|n -The web client start out having two panes. The bottom one is where you insert commands -and the top one is where you see returns from the server. +The web client starts out having two panes - the input-pane for entering commands +and the main window. - Use |y<Return>|n (or click the arrow on the right) to send your input. -- Use |yCtrl + <up-arrow>|n to step back and repeat a command you entered previously. +- Use |yCtrl + <up/down-arrow>|n to step back and forth in your command-history. - Use |yCtrl + <Return>|n to add a new line to your input without sending. +(Cmd instead of Ctrl-key on Macs) -If you want there is some |wextra|n info to learn about customizing the webclient. +There is also some |wextra|n info to learn about customizing the webclient. ## OPTIONS - + back: about_muds - extra: learn more about customizing the webclient -> customizing the webclient - next: general command input -> command_input + extra: more details about customizing the webclient -> customizing the webclient + next: general command tutorial -> goto_command_demo_1() back to top: start >: back @@ -109,10 +217,10 @@ If you want there is some |wextra|n info to learn about customizing the webclien ## NODE customizing the webclient -|y1)|n The panes of the webclient can be resized and you can create additional panes. +|y1)|n The panes of the webclient can be resized and you can create additional panes. -- Press the little plus (|w+|n) sign in the top left and a new tab will appear. -- Click and drag the tab and pull it far to the right and release when it creates two +- Press the little plus (|w+|n) sign in the top left and a new tab will appear. +- Click and drag the tab and pull it far to the right and release when it creates two panes next to each other. |y2)|n You can have certain server output only appear in certain panes. @@ -122,34 +230,99 @@ If you want there is some |wextra|n info to learn about customizing the webclien - Click the diamond again so the menu closes. - Next, write "|ytest Hello world!|n". A test-text should appear in your rightmost pane! -|y3)|n You can customize general webclient settings by pressing the cogwheel in the upper +|y3)|n You can customize general webclient settings by pressing the cogwheel in the upper left corner. It allows to change things like font and if the client should play sound. The "message routing" allows for rerouting text matching a certain regular expression (regex) -to a web client pane with a specific tag that you set yourself. +to a web client pane with a specific tag that you set yourself. |y4)|n Close the right-hand pane with the |wX|n in the rop right corner. -## OPTIONS +## OPTIONS back: using webclient - next: general command input -> command_input + next: general command input -> goto_command_demo_1() back to top: start > test *: send tagged message to new pane -> send_testing_tagged() # --------------------------------------------------------------------------------- -## NODE command_input +# we get here via goto_command_demo_1() -The first thing to learn is to use the |yhelp|n command. +## NODE command_demo_1 -## OPTIONS +Evennia has about 90 default commands. They include useful administration/building +commands and a few limited "in-game" commands to serve as examples. They are intended +to be changed, extended and modified as you please. + +The most important and common command you have is '|ylook|n'. It's also +abbreviated '|yl|n' since it's used so much. It displays/redisplays your current +location. + +Try |ylook|n now. You have been transported to a sunny cabin to look around in. + +## OPTIONS back: using webclient - next: (end) -> end + next: help on help -> goto_command_demo_2() back to top: start - > h|help: command_passthrough(cmd=help) - + + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_2() + +## NODE command_demo_2 + +Evennia commands can change meaning depending on context. We left the sunny +cabin now and if you try |ylook|n again you will just re-display this menu +(try it!). Instead you have some other commands available to try out. + +First is |yhelp|n. This lists all commands |wcurrently|n available to you. In +the future you could also add your own topics about your game, world, rules etc. + +Only a few commands are made available while in this tutorial. Once you exit +you'll find a lot more! + +(ignore the the <menu commands>, it's just indicating that you have the ability +to use the default functionality of this tutorial menu, like choosing options). + +Use |yhelp help|n to see how to use the help command. Most often you'll just do + + help <topic> + +In the coming pages we'll test out these available commands. + +## OPTIONS + + back: back to the cabin -> goto_command_demo_1() + next: talk on channels -> talk on channels + back to top: start + +# --------------------------------------------------------------------------------- + +## NODE talk on channels + +|wChannels|n are like in-game chatrooms. The |wChannel names|n help-category +holds the names of the channels available to you right now. One such channel is +|wpublic|n. Use |yhelp public|n to see how to use it. Try it: + + |ypublic Hello World!|n + +This will send a message to the |wpublic|n channel where everyone on that +channel can see it. If someone else is on your server, you may get a reply! + +Evennia can link its in-game channels to external chat networks. This allows +you to talk with people not actually logged into the game. For +example, the online Evennia-demo links its |wpublic|n channel to the #evennia +IRC support channel, which in turn links to a Discord channel! + +## OPTIONS + + back: help on help -> goto_command_demo_2() + next: end + back to top: start + # --------------------------------------------------------------------------------- ## NODE end @@ -165,9 +338,18 @@ GOTO_CALLABLES = { "send_testing_tagged": send_testing_tagged, "do_nothing": do_nothing, "send_string": send_string, + "goto_command_demo_1": goto_command_demo_1, + "goto_command_demo_2": goto_command_demo_2, } +class TutorialEvMenu(EvMenu): + def close_menu(self): + """Custom cleanup actions when closing menu""" + _maintain_demo_room(self.caller, delete=True) + super().close_menu() + + def testmenu(caller): menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) # we'll use a custom EvMenu child later - EvMenu(caller, menutree) + TutorialEvMenu(caller, menutree) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index ca803d46b6..d525172b4f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -358,6 +358,10 @@ class CmdEvMenuNode(Command): aliases = [_CMD_NOMATCH] locks = "cmd:all()" help_category = "Menu" + auto_help_display_key = "<menu commands>" + + def get_help(self): + return "Menu commands are explained within the menu." def func(self): """ @@ -1135,7 +1139,7 @@ class EvMenu: else: self.caller.msg(_HELP_NO_OPTION_MATCH, session=self._session) except EvMenuGotoAbortMessage as err: - # custom interrupt from inside a goto callable - print the message and + # custom interrupt from inside a goto callable - print the message and # stay on the current node. self.caller.msg(str(err), session=self._session) @@ -1672,7 +1676,7 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): return _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs) # no match, show error - + raise EvMenuGotoAbortMessage(_HELP_NO_OPTION_MATCH) From dda2493dba7f3606dcb8b8cb746186aa233e38aa Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sat, 3 Oct 2020 00:10:14 +0200 Subject: [PATCH 15/34] Change order of tutorialmenu nodes --- .../contrib/tutorial_world/tutorialmenu.py | 260 ++++++++++++------ 1 file changed, 174 insertions(+), 86 deletions(-) diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/tutorialmenu.py index dd05e3fc19..e40eb1a028 100644 --- a/evennia/contrib/tutorial_world/tutorialmenu.py +++ b/evennia/contrib/tutorial_world/tutorialmenu.py @@ -60,7 +60,55 @@ The box is made of wood. On it, letters are engraved, reading: it's because you are playing with Builder-privileges or higher. Regular players will not see the numbers. - Write |ynext|n to leave the cabin and continue with the tutorial. + Next look at the |wdoor|n. + +""" + +_DOOR_DESC_OUT = """ +This is a solid wooden door leading to the outside of the cabin. Some +text is written on it: + + This is an |wexit|n. An exit is often named by its compass-direction like + |weast|n, |wwest|n, |wnorthwest|n and so on, but it could be named + anything, like this door. To use the exit, you just write its name. So by + writing |ydoor|n you will leave the cabin. + +""" + +_DOOR_DESC_IN = """ +This is a solid wooden door leading to the inside of the cabin. On +are some carved text: + + This exit leads back into the cabin. An exit is just like any object, + so while has a name, it can also have aliases. To get back inside + you can both write |ydoor|n but also |yin|n. + +""" + +_MEADOW_DESC = """ +This is a lush meadow, just outside a cozy cabin. It's surrounded +by trees and sunlight filters down from a clear blue sky. + +There is a |wstone|n here. Try looking at it. + +""" + +_STONE_DESC = """ +This is a fist-sized stone covered in runes: + + To pick me up, use + + |yget stone|n + + You can see what you carry with the |yinventory|n (|yi|n). + + To drop me again, just write + + |ydrop stone|n + + Use |ynext|n when you are done exploring and want to + continue with the tutorial. + """ def _maintain_demo_room(caller, delete=False): @@ -73,47 +121,88 @@ def _maintain_demo_room(caller, delete=False): if delete: if roomdata: - prev_loc, room, obj = roomdata + # we delete directly for simplicity. We need to delete + # in specific order to avoid deleting rooms moves + # its contents to their default home-location + prev_loc, room1, box, room2, stone, door_out, door_in = roomdata caller.location = prev_loc - obj.delete() - room.delete() + box.delete() + stone.delete() + door_out.delete() + door_in.delete() + room1.delete() + room2.delete() del caller.db.tutorial_world_demo_room_data elif not roomdata: - room = create_object("evennia.objects.objects.DefaultRoom", + # create and describe the cabin and box + room1 = create_object("evennia.objects.objects.DefaultRoom", key="A small, cozy cabin") - room.db.desc = _ROOM_DESC.strip() - obj = create_object("evennia.objects.objects.DefaultObject", - key="A small wooden box") - obj.db.desc = _BOX_DESC.strip() - obj.location = room - # move caller into room and store - caller.db.tutorial_world_demo_room_data = (caller.location, room, obj) - caller.location = room + room1.db.desc = _ROOM_DESC.strip() + box = create_object("evennia.objects.objects.DefaultObject", + key="small wooden box") + box.db.desc = _BOX_DESC.strip() + box.location = room1 -class DemoCommandSet1(CmdSet): + # create and describe the meadow and stone + room2 = create_object("evennia.objects.objects.DefaultRoom", + key="A lush summer meadow") + room2.db.desc = _MEADOW_DESC.strip() + stone = create_object("evennia.objects.objects.DefaultObject", + key="carved stone") + stone.db.desc = _STONE_DESC.strip() + + # make the linking exits + door_out = create_object("evennia.objects.objects.DefaultExit", + key="Door", + location=room1, + destination=room2) + door_out.db.desc = _DOOR_DESC_OUT.strip() + door_in = create_object("evennia.objects.objects.DefaultExit", + key="entrance to the cabin", + aliases=["door", "in"], + location=room2, + destination=room1) + door_in.db.desc = _DOOR_DESC_IN.strip() + + # store references for easy removal later + caller.db.tutorial_world_demo_room_data = (caller.location, + room1, box, + room2, stone, + door_out, door_in) + # move caller into room + caller.location = room1 + +class DemoCommandSetRoom(CmdSet): """ Demo the `look` command. """ - key = "cmd_demo_cmdset_1" + key = "cmd_demo_cmdset_room" priority = 2 + no_exits = False def at_cmdset_creation(self): from evennia import default_cmds + self.add(default_cmds.CmdHelp()) self.add(default_cmds.CmdLook()) + self.add(default_cmds.CmdGet()) + self.add(default_cmds.CmdDrop()) + self.add(default_cmds.CmdExamine()) + self.add(default_cmds.CmdPy()) -def goto_command_demo_1(caller, raw_string, **kwargs): - """Generate a little room environment for testing out some commands.""" +def goto_command_demo_room(caller, raw_string, **kwargs): + """Generate a little 2-room environment for testing out some commands.""" _maintain_demo_room(caller) - caller.cmdset.add(DemoCommandSet1) # TODO - make persistent - return "command_demo_1" + caller.cmdset.remove(DemoCommandSetRoom) + caller.cmdset.add(DemoCommandSetRoom) # TODO - make persistent + return "command_demo_room" # resources for the general command demo -class DemoCommandSet2(CmdSet): +class DemoCommandSetHelp(CmdSet): """ Demo other commands. """ - key = "cmd_demo_cmdset_2" + key = "cmd_demo_cmdset_help" priority = 2 def at_cmdset_creation(self): @@ -121,11 +210,11 @@ class DemoCommandSet2(CmdSet): self.add(default_cmds.CmdHelp()) -def goto_command_demo_2(caller, raw_string, **kwargs): +def goto_command_demo_help(caller, raw_string, **kwargs): _maintain_demo_room(caller, delete=True) - caller.cmdset.remove(DemoCommandSet1) - caller.cmdset.add(DemoCommandSet2) # TODO - make persistent - return "command_demo_2" + caller.cmdset.remove(DemoCommandSetRoom) + caller.cmdset.add(DemoCommandSetHelp) # TODO - make persistent + return "command_demo_help" def command_passthrough(caller, raw_string, **kwargs): @@ -142,18 +231,20 @@ MENU_TEMPLATE = """ ## NODE start -Welcome to |cEvennia|n! From this menu you can learn some more about the system and -also the basics of how to play a text-based game. You can exit this menu at -any time by using "q" or "quit". +Welcome to the |cEvennia|n intro! From this menu you can learn some more about +the system and also the basics of how to play a text-based game. You can exit +this menu at any time by using "q" or "quit". -Select an option you want to learn more about below. +For (a lot) more help, check out the documentation at http://www.evennia.com. + +Write |wnext|n to continue or select a number to jump to that lesson. ## OPTIONS - 1: About evennia -> about_evennia + 1 (next);1;next;n: About Evennia -> about_evennia 2: What is a MUD/MU*? -> about_muds 3: Using the webclient -> using webclient - 4: Using commands -> goto_command_demo_1() + 4: Playing the game -> goto_command_demo_help() # --------------------------------------------------------------------------------- @@ -163,8 +254,8 @@ Evennia is a game engine for creating multiplayer online text-games. ## OPTIONS - back: start - next: about MUDs -> about_muds + back;b: Start -> start + next;n: About MUDs -> about_muds >: about_muds # --------------------------------------------------------------------------------- @@ -177,9 +268,9 @@ to graphical MMORPG-style games like World of Warcraft. ## OPTIONS - back: about_evennia - next: using the webclient -> using webclient - back to top: start + back;b: About Evennia -> about_evennia + next;n: Using the webclient -> using webclient + back to top;t: start >: using webclient # --------------------------------------------------------------------------------- @@ -207,14 +298,16 @@ There is also some |wextra|n info to learn about customizing the webclient. ## OPTIONS - back: about_muds - extra: more details about customizing the webclient -> customizing the webclient - next: general command tutorial -> goto_command_demo_1() + back: About MUDs -> about_muds + extra: Customizing the webclient -> customizing the webclient + next: Playing the game -> goto_command_demo_help() back to top: start - >: back + >: goto_command_demo_help() # --------------------------------------------------------------------------------- +# this is a dead-end 'leaf' of the menu + ## NODE customizing the webclient |y1)|n The panes of the webclient can be resized and you can create additional panes. @@ -241,63 +334,37 @@ to a web client pane with a specific tag that you set yourself. ## OPTIONS back: using webclient - next: general command input -> goto_command_demo_1() - back to top: start > test *: send tagged message to new pane -> send_testing_tagged() # --------------------------------------------------------------------------------- -# we get here via goto_command_demo_1() +# we get here via goto_command_demo_help() -## NODE command_demo_1 +## NODE command_demo_help Evennia has about 90 default commands. They include useful administration/building commands and a few limited "in-game" commands to serve as examples. They are intended to be changed, extended and modified as you please. -The most important and common command you have is '|ylook|n'. It's also -abbreviated '|yl|n' since it's used so much. It displays/redisplays your current -location. +First to try is |yhelp|n. This lists all commands |wcurrently|n available to you. -Try |ylook|n now. You have been transported to a sunny cabin to look around in. +Use |yhelp <topic>|n to get specific help. Try |yhelp help|n to get help on using +the help command. For your game you could add help about your game, lore, rules etc +as well. + +At the moment you only have |whelp|n and some |wChannel Names|n (the '<menu commands>' +is just a placeholder to indicate you are using this menu). + +We'll add more commands as we get to them in this tutorial - but we'll only +cover a small handfull. Once you exit you'll find a lot more! Now let's try +those channels ... ## OPTIONS - back: using webclient - next: help on help -> goto_command_demo_2() - back to top: start - - -# --------------------------------------------------------------------------------- - -# we get here via goto_command_demo_2() - -## NODE command_demo_2 - -Evennia commands can change meaning depending on context. We left the sunny -cabin now and if you try |ylook|n again you will just re-display this menu -(try it!). Instead you have some other commands available to try out. - -First is |yhelp|n. This lists all commands |wcurrently|n available to you. In -the future you could also add your own topics about your game, world, rules etc. - -Only a few commands are made available while in this tutorial. Once you exit -you'll find a lot more! - -(ignore the the <menu commands>, it's just indicating that you have the ability -to use the default functionality of this tutorial menu, like choosing options). - -Use |yhelp help|n to see how to use the help command. Most often you'll just do - - help <topic> - -In the coming pages we'll test out these available commands. - -## OPTIONS - - back: back to the cabin -> goto_command_demo_1() - next: talk on channels -> talk on channels + back: Using the webclient -> using webclient + next: Channel commands -> talk on channels back to top: start + >: talk on channels # --------------------------------------------------------------------------------- @@ -315,14 +382,35 @@ channel can see it. If someone else is on your server, you may get a reply! Evennia can link its in-game channels to external chat networks. This allows you to talk with people not actually logged into the game. For example, the online Evennia-demo links its |wpublic|n channel to the #evennia -IRC support channel, which in turn links to a Discord channel! +IRC support channel. ## OPTIONS - back: help on help -> goto_command_demo_2() + back: help on help -> goto_command_demo_help() + next: Moving and exploring -> goto_command_demo_room() + back to top: start + >: goto_command_demo_room() + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_room() + +## NODE command_demo_room + +Another important command is '|ylook|n'. It's also abbreviated '|yl|n' since +it's used so much. Looking displays/redisplays your current location. So far in +this tutorial, using 'look' would just redisplay the menu. + +Try |ylook|n now. You have been quietly transported to a sunny cabin to look +around in. Explore a little. Use |ynext|n when you are done. + +## OPTIONS + + back: Channel commands -> talk on channels next: end back to top: start + # --------------------------------------------------------------------------------- ## NODE end @@ -338,8 +426,8 @@ GOTO_CALLABLES = { "send_testing_tagged": send_testing_tagged, "do_nothing": do_nothing, "send_string": send_string, - "goto_command_demo_1": goto_command_demo_1, - "goto_command_demo_2": goto_command_demo_2, + "goto_command_demo_help": goto_command_demo_help, + "goto_command_demo_room": goto_command_demo_room, } class TutorialEvMenu(EvMenu): From 9bb238c3d411409881a9cbc8a4ad40aaa949b163 Mon Sep 17 00:00:00 2001 From: ChrisLR <arzhul@gmail.com> Date: Sat, 3 Oct 2020 15:41:37 -0400 Subject: [PATCH 16/34] Removed SplitHandler from the docs and plugins folder --- docs/source/Webclient.md | 75 +-- .../webclient/js/plugins/splithandler.js | 438 ------------------ .../webclient/templates/webclient/base.html | 1 - 3 files changed, 2 insertions(+), 512 deletions(-) delete mode 100644 evennia/web/webclient/static/webclient/js/plugins/splithandler.js diff --git a/docs/source/Webclient.md b/docs/source/Webclient.md index 610ad667cf..97f465eabd 100644 --- a/docs/source/Webclient.md +++ b/docs/source/Webclient.md @@ -69,12 +69,11 @@ The order of the plugins defined in `base.html` is important. All the callbacks * `oob.js` Defines onSend. Allows the user to test/send Out Of Band json messages to the server. * `options.js` Defines most callbacks. Provides a popup-based UI to coordinate options settings with the server. * `options2.js` Defines most callbacks. Provides a goldenlayout-based version of the options/settings tab. Integrates with other plugins via the custom onOptionsUI callback. -* `popups.js` Provides default popups/Dialog UI for other plugins to use. -* `splithandler.js` Defines onText. Provides an older, less-flexible alternative to goldenlayout for multi-window UI to automatically separate out screen real-estate by type of message. +* `popups.js` Provides default popups/Dialog UI for other plugins to use. # Writing your own Plugins -So, you love the functionality of the webclient, but your game has specific types of text that need to be separated out into their own space, visually. There are two plugins to help with this. The Goldenlayout plugin framework, and the older Splithandler framework. +So, you love the functionality of the webclient, but your game has specific types of text that need to be separated out into their own space, visually. The Goldenlayout plugin framework can help with this. ## GoldenLayout @@ -189,73 +188,3 @@ window.plugin_handler.add("myplugin", myplugin); You can then add "mycomponent" to an item's componentName in your goldenlayout_default_config.js. Make sure to stop your server, evennia collectstatic, and restart your server. Then make sure to clear your browser cache before loading the webclient page. - - -## Older Splithandler -The splithandler.js plugin provides a means to do this, but you don't want to have to force every player to set up their own layout every time they use the client. - -Let's create a `mygame/web/static_overrides/webclient/js/plugins/layout.js` plugin! - -First up, follow the directions in Customizing the Web Client section above to override the base.html. - -Next, add the new plugin to your copy of base.html: -``` -<script src={% static "webclient/js/plugins/layout.js" %} language="javascript" type="text/javascript"></script> -``` -Remember, plugins are load-order dependent, so make sure the new `<script>` tag comes after the splithandler.js - -And finally create the layout.js file and add the minimum skeleton of a plugin to it: - -``` -// my new plugin -var my_plugin = (function () { - let init = function () { - console.log("myplugin! Hello World!"); - } - - return { - init: init, - } -})(); -plugin_handler.add("myplugin", my_plugin); -``` - -Now, `evennia stop`, `evennia collectstatic`, and `evennia start` and then load the webclient up in your browser. -Enable developer options and look in the console, and you should see the message 'myplugin! Hello World!' - -Since our layout.js plugin is going to use the splithandler, let's enhance this by adding a check to make sure the splithandler.js plugin has been loaded: - -change the above init function to: -``` -let init = function () { - let splithandler = plugins['splithandler']; - if( splithandler ) { - console.log("MyPlugin initialized"); - } else { - alert('MyPlugin requires the splithandler.js plugin. Please contact the game maintainer to correct this'); - } -} -``` - -And finally, the splithandler.js provides provides two functions to cut up the screen real-estate: - `dynamic_split( pane_name_to_cut_apart, direction_of_split, new_pane_name1, new_pane_name2, text_flow_pane1, text_flow_pane2, array_of_split_percentages )` -and - `set_pane_types( pane_to_set, array_of_known_message_types_to_assign)` - -In this case, we'll cut it into 3 panes, 1 bigger, two smaller, and assign 'help' messages to the top-right pane: -``` -let init = function () { - let splithandler = plugins['splithandler']; - if( splithandler ) { - splithandler.dynamic_split("main","horizontal","left","right","linefeed","linefeed",[50,50]); - splithandler.dynamic_split("right","vertical","help","misc","replace","replace",[50,50]); - splithandler.set_pane_types('help', ['help']); - - console.log("MyPlugin initialized"); - } else { - alert('MyPlugin requires the splithandler.js plugin. Please contact the game maintainer to correct this'); - } -} -``` - -`evennia stop`, `evennia collectstatic`, and `evennia start` once more, and force-reload your browser page to clear any cached version. You should now have a nicely split layout. \ No newline at end of file diff --git a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js b/evennia/web/webclient/static/webclient/js/plugins/splithandler.js deleted file mode 100644 index 284d74b912..0000000000 --- a/evennia/web/webclient/static/webclient/js/plugins/splithandler.js +++ /dev/null @@ -1,438 +0,0 @@ -/* - * - * Plugin to use split.js to create a basic windowed ui - * - */ -let splithandler_plugin = (function () { - - var num_splits = 0; - var split_panes = {}; - var backout_list = []; - - var known_types = ['all', 'rest']; - - // Exported Functions - - // - // function to assign "Text types to catch" to a pane - var set_pane_types = function (splitpane, types) { - split_panes[splitpane]['types'] = types; - } - - // - // Add buttons to the Evennia webcilent toolbar - function addToolbarButtons () { - var toolbar = $('#toolbar'); - toolbar.append( $('<button id="splitbutton" type="button">⇹</button>') ); - toolbar.append( $('<button id="panebutton" type="button">⚙</button>') ); - toolbar.append( $('<button id="undobutton" type="button">↶</button>') ); - $('#undobutton').hide(); - } - - function addSplitDialog () { - plugins['popups'].createDialog('splitdialog', 'Split Pane', ''); - } - - function addPaneDialog () { - plugins['popups'].createDialog('panedialog', 'Assign Pane Options', ''); - } - - // - // Handle resizing the InputField after a client resize event so that the splits dont get too big. - function resizeInputField () { - var wrapper = $("#inputform") - var input = $("#inputcontrol") - var prompt = $("#prompt") - - input.height( wrapper.height() - (input.offset().top - wrapper.offset().top) ); - } - - // - // Handle resizing of client - function doWindowResize() { - var resizable = $("[data-update-append]"); - var parents = resizable.closest(".split"); - - resizeInputField(); - - parents.animate({ - scrollTop: parents.prop("scrollHeight") - }, 0); - } - - // - // create a new UI split - var dynamic_split = function (splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { - // find the sub-div of the pane we are being asked to split - splitpanesub = splitpane + '-sub'; - - // create the new div stack to replace the sub-div with. - var first_div = $( '<div id="'+pane_name1+'" class="split split-'+direction+'" />' ) - var first_sub = $( '<div id="'+pane_name1+'-sub" class="split-sub" />' ) - var second_div = $( '<div id="'+pane_name2+'" class="split split-'+direction+'" />' ) - var second_sub = $( '<div id="'+pane_name2+'-sub" class="split-sub" />' ) - - // check to see if this sub-pane contains anything - contents = $('#'+splitpanesub).contents(); - if( contents ) { - // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) - contents.appendTo(first_sub); - } - first_div.append( first_sub ); - second_div.append( second_sub ); - - // update the split_panes array to remove this pane name, but store it for the backout stack - var backout_settings = split_panes[splitpane]; - delete( split_panes[splitpane] ); - - // now vaporize the current split_N-sub placeholder and create two new panes. - $('#'+splitpane).append(first_div); - $('#'+splitpane).append(second_div); - $('#'+splitpane+'-sub').remove(); - - // And split - Split(['#'+pane_name1,'#'+pane_name2], { - direction: direction, - sizes: sizes, - gutterSize: 4, - minSize: [50,50], - }); - - // store our new split sub-divs for future splits/uses by the main UI. - split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; - split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; - - // add our new split to the backout stack - backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); - - $('#undobutton').show(); - } - - // - // Reverse the last UI split - var undo_split = function () { - // pop off the last split pair - var back = backout_list.pop(); - if( !back ) { - return; - } - - if( backout_list.length === 0 ) { - $('#undobutton').hide(); - } - - // Collect all the divs/subs in play - var pane1 = back['pane1']; - var pane2 = back['pane2']; - var pane1_sub = $('#'+pane1+'-sub'); - var pane2_sub = $('#'+pane2+'-sub'); - var pane1_parent = $('#'+pane1).parent(); - var pane2_parent = $('#'+pane2).parent(); - - if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { - // sanity check failed...somebody did something weird...bail out - console.log( pane1 ); - console.log( pane2 ); - console.log( pane1_parent ); - console.log( pane2_parent ); - return; - } - - // create a new sub-pane in the panes parent - var parent_sub = $( '<div id="'+pane1_parent.attr('id')+'-sub" class="split-sub" />' ) - - // check to see if the special #messagewindow is in either of our sub-panes. - var msgwindow = pane1_sub.find('#messagewindow') - if( !msgwindow ) { - //didn't find it in pane 1, try pane 2 - msgwindow = pane2_sub.find('#messagewindow') - } - if( msgwindow ) { - // It is, so collect all contents into it instead of our parent_sub div - // then move it to parent sub div, this allows future #messagewindow divs to flow properly - msgwindow.append( pane1_sub.contents() ); - msgwindow.append( pane2_sub.contents() ); - parent_sub.append( msgwindow ); - } else { - //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane - parent_sub.append( pane1_sub.contents() ); - parent_sub.append( pane2_sub.contents() ); - } - - // clear the parent - pane1_parent.empty(); - - // add the new sub-pane back to the parent div - pane1_parent.append(parent_sub); - - // pull the sub-div's from split_panes - delete split_panes[pane1]; - delete split_panes[pane2]; - - // add our parent pane back into the split_panes list for future splitting - split_panes[pane1_parent.attr('id')] = back['undo']; - } - - // - // UI elements - // - - // - // Draw "Split Controls" Dialog - var onSplitDialog = function () { - var dialog = $("#splitdialogcontent"); - dialog.empty(); - - var selection = '<select name="pane">'; - for ( var pane in split_panes ) { - selection = selection + '<option value="' + pane + '">' + pane + '</option>'; - } - selection = "Pane to split: " + selection + "</select> "; - dialog.append(selection); - - dialog.append('<input type="radio" name="direction" value="vertical" checked>top/bottom </>'); - dialog.append('<input type="radio" name="direction" value="horizontal">side-by-side <hr />'); - - dialog.append('Pane 1: <input type="text" name="new_pane1" value="" />'); - dialog.append('<input type="radio" name="flow1" value="linefeed" checked>newlines </>'); - dialog.append('<input type="radio" name="flow1" value="replace">replace </>'); - dialog.append('<input type="radio" name="flow1" value="append">append <hr />'); - - dialog.append('Pane 2: <input type="text" name="new_pane2" value="" />'); - dialog.append('<input type="radio" name="flow2" value="linefeed" checked>newlines </>'); - dialog.append('<input type="radio" name="flow2" value="replace">replace </>'); - dialog.append('<input type="radio" name="flow2" value="append">append <hr />'); - - dialog.append('<div id="splitclose" class="btn btn-large btn-outline-primary float-right">Split</div>'); - - $("#splitclose").bind("click", onSplitDialogClose); - - plugins['popups'].togglePopup("#splitdialog"); - } - - // - // Close "Split Controls" Dialog - var onSplitDialogClose = function () { - var pane = $("select[name=pane]").val(); - var direction = $("input[name=direction]:checked").attr("value"); - var new_pane1 = $("input[name=new_pane1]").val(); - var new_pane2 = $("input[name=new_pane2]").val(); - var flow1 = $("input[name=flow1]:checked").attr("value"); - var flow2 = $("input[name=flow2]:checked").attr("value"); - - if( new_pane1 == "" ) { - new_pane1 = 'pane_'+num_splits; - num_splits++; - } - - if( new_pane2 == "" ) { - new_pane2 = 'pane_'+num_splits; - num_splits++; - } - - if( document.getElementById(new_pane1) ) { - alert('An element: "' + new_pane1 + '" already exists'); - return; - } - - if( document.getElementById(new_pane2) ) { - alert('An element: "' + new_pane2 + '" already exists'); - return; - } - - dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); - - plugins['popups'].closePopup("#splitdialog"); - } - - // - // Draw "Pane Controls" dialog - var onPaneControlDialog = function () { - var dialog = $("#panedialogcontent"); - dialog.empty(); - - var selection = '<select name="assign-pane">'; - for ( var pane in split_panes ) { - selection = selection + '<option value="' + pane + '">' + pane + '</option>'; - } - selection = "Assign to pane: " + selection + "</select> <hr />"; - dialog.append(selection); - - var multiple = '<select multiple name="assign-type">'; - for ( var type in known_types ) { - multiple = multiple + '<option value="' + known_types[type] + '">' + known_types[type] + '</option>'; - } - multiple = "Content types: " + multiple + "</select> <hr />"; - dialog.append(multiple); - - dialog.append('<div id="paneclose" class="btn btn-large btn-outline-primary float-right">Assign</div>'); - - $("#paneclose").bind("click", onPaneControlDialogClose); - - plugins['popups'].togglePopup("#panedialog"); - } - - // - // Close "Pane Controls" dialog - var onPaneControlDialogClose = function () { - var pane = $("select[name=assign-pane]").val(); - var types = $("select[name=assign-type]").val(); - - // var types = new Array; - // $('#splitdialogcontent input[type=checkbox]:checked').each(function() { - // types.push( $(this).attr('value') ); - // }); - - set_pane_types( pane, types ); - - plugins['popups'].closePopup("#panedialog"); - } - - // - // helper function sending text to a pane - var txtToPane = function (panekey, txt) { - var pane = split_panes[panekey]; - var text_div = $('#' + panekey + '-sub'); - - if ( pane['update_method'] == 'replace' ) { - text_div.html(txt) - } else if ( pane['update_method'] == 'append' ) { - text_div.append(txt); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } else { // line feed - text_div.append("<div class='out'>" + txt + "</div>"); - var scrollHeight = text_div.parent().prop("scrollHeight"); - text_div.parent().animate({ scrollTop: scrollHeight }, 0); - } - } - - - // - // plugin functions - // - - - // - // Accept plugin onText events - var onText = function (args, kwargs) { - // If the message is not itself tagged, we'll assume it - // should go into any panes with 'all' or 'rest' set - var msgtype = "rest"; - - if ( kwargs && 'type' in kwargs ) { - msgtype = kwargs['type']; - if ( ! known_types.includes(msgtype) ) { - // this is a new output type that can be mapped to panes - console.log('detected new output type: ' + msgtype) - known_types.push(msgtype); - } - } - var target_panes = []; - var rest_panes = []; - - for (var key in split_panes) { - var pane = split_panes[key]; - // is this message type mapped to this pane (or does the pane has an 'all' type)? - if (pane['types'].length > 0) { - if (pane['types'].includes(msgtype) || pane['types'].includes('all')) { - target_panes.push(key); - } else if (pane['types'].includes('rest')) { - // store rest-panes in case we have no explicit to send to - rest_panes.push(key); - } - } else { - // unassigned panes are assumed to be rest-panes too - rest_panes.push(key); - } - } - var ntargets = target_panes.length; - var nrests = rest_panes.length; - if (ntargets > 0) { - // we have explicit target panes to send to - for (var i=0; i<ntargets; i++) { - txtToPane(target_panes[i], args[0]); - } - return true; - } else if (nrests > 0) { - // no targets, send remainder to rest-panes/unassigned - for (var i=0; i<nrests; i++) { - txtToPane(rest_panes[i], args[0]); - } - return true; - } - // unhandled message - return false; - } - - // - // onKeydown check for 'ESC' key. - var onKeydown = function (event) { - var code = event.which; - - if (code === 27) { // Escape key - if ($('#splitdialog').is(':visible')) { - plugins['popups'].closePopup("#splitdialog"); - return true; - } - if ($('#panedialog').is(':visible')) { - plugins['popups'].closePopup("#panedialog"); - return true; - } - } - - // capture all keys while one of our "modal" dialogs is open - if ($('#splitdialogcontent').is(':visible') || $('#panedialogcontent').is(':visible')) { - return true; - } - - return false; - } - - // - // Required plugin "init" function - var init = function(settings) { - known_types.push('help'); - - Split(['#main','#input'], { - direction: 'vertical', - sizes: [90,10], - gutterSize: 4, - minSize: [50,50], - }); - - split_panes['main'] = { 'types': [], 'update_method': 'linefeed' }; - - // Create our UI - addToolbarButtons(); - addSplitDialog(); - addPaneDialog(); - - // Register our utility button events - $("#splitbutton").bind("click", onSplitDialog); - $("#panebutton").bind("click", onPaneControlDialog); - $("#undobutton").bind("click", undo_split); - - // Event when client window changes - $(window).bind("resize", doWindowResize); - - $("[data-role-input]").bind("resize", doWindowResize) - .bind("paste", resizeInputField) - .bind("cut", resizeInputField); - - // Event when any key is pressed - $(document).keyup(resizeInputField); - - console.log("Splithandler Plugin Initialized."); - } - - return { - init: init, - onText: onText, - dynamic_split: dynamic_split, - undo_split: undo_split, - set_pane_types: set_pane_types, - onKeydown: onKeydown, - } -})() -plugin_handler.add('splithandler', splithandler_plugin); diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index e65467da12..96af22340c 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -64,7 +64,6 @@ JQuery available. <!-- set up splits before loading the GUI --> <!-- - <script src="https://unpkg.com/split.js@1.5.9/dist/split.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.3.0/mustache.min.js"></script> --> <script type="text/javascript" src="https://golden-layout.com/files/latest/js/goldenlayout.min.js"></script> From cb18e67c3ade67bc14322c5a6dccce312e93c13c Mon Sep 17 00:00:00 2001 From: ChrisLR <arzhul@gmail.com> Date: Sat, 3 Oct 2020 16:21:37 -0400 Subject: [PATCH 17/34] Uppercased failing doc build reference, is docbuild case sensitive? --- docs/source/toc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/toc.md b/docs/source/toc.md index b82dc4f013..f155f146cf 100644 --- a/docs/source/toc.md +++ b/docs/source/toc.md @@ -1,5 +1,5 @@ # Toc -- [API root](api/evennia-api.rst) +- [API root](api/Evennia-API.rst) - [./A voice operated elevator using events](./A-voice-operated-elevator-using-events) - [./API refactoring](./API-refactoring) - [./Accounts](./Accounts) From 885674c323c7e48f60bc9246b1dcc139d612a67e Mon Sep 17 00:00:00 2001 From: ChrisLR <arzhul@gmail.com> Date: Sat, 3 Oct 2020 16:25:26 -0400 Subject: [PATCH 18/34] Revert "Uppercased failing doc build reference, is docbuild case sensitive?" --- docs/source/toc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/toc.md b/docs/source/toc.md index f155f146cf..b82dc4f013 100644 --- a/docs/source/toc.md +++ b/docs/source/toc.md @@ -1,5 +1,5 @@ # Toc -- [API root](api/Evennia-API.rst) +- [API root](api/evennia-api.rst) - [./A voice operated elevator using events](./A-voice-operated-elevator-using-events) - [./API refactoring](./API-refactoring) - [./Accounts](./Accounts) From 2eaa947ed4e1b8b20fb1867739a09918bc14545f Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sat, 3 Oct 2020 20:33:31 +0200 Subject: [PATCH 19/34] Improvements to cmdsethandler/cmdset debugging --- CHANGELOG.md | 9 +- evennia/commands/cmdhandler.py | 5 + evennia/commands/cmdset.py | 57 +- evennia/commands/cmdsethandler.py | 63 +- evennia/commands/default/building.py | 60 +- evennia/commands/default/tests.py | 3 +- evennia/commands/tests.py | 747 +++++++++++++++++- .../contrib/tutorial_world/tutorialmenu.py | 1 + evennia/utils/evmenu.py | 67 +- 9 files changed, 904 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6665a90f4..a691a5c64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - new `drop:holds()` lock default to limit dropping nonsensical things. Access check defaults to True for backwards-compatibility in 0.9, will be False in 1.0 -### Already in master +### Evennia 0.95 (master) - `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False - `py` command now reroutes stdout to output results in-game client. `py` without arguments starts a full interactive Python console. @@ -74,7 +74,12 @@ without arguments starts a full interactive Python console. pagination (e.g. to create EvTables for every page instead of splittine one table) - Using `EvMore pagination`, dramatically improves performance of `spawn/list` and `scripts` listings (100x speed increase for displaying 1000+ prototypes/scripts). - +- `EvMenu` now uses the more logically named `.ndb._evmenu` instead of `.ndb._menutree` to store itself. + Both still work for backward compatibility, but `_menutree` is deprecated. +- `EvMenu.msg(txt)` added as a central place to send text to the user, makes it easier to override. + Default `EvMenu.msg` sends with OOB type="menu" for use with OOB and webclient pane-redirects. +- New EvMenu templating system for quickly building simpler EvMenus without as much code. + ## Evennia 0.9 (2018-2019) diff --git a/evennia/commands/cmdhandler.py b/evennia/commands/cmdhandler.py index b22a80ed19..a8839f2ad0 100644 --- a/evennia/commands/cmdhandler.py +++ b/evennia/commands/cmdhandler.py @@ -494,6 +494,11 @@ def get_and_merge_cmdsets(caller, session, account, obj, callertype, raw_string) cmdset = None for cset in (cset for cset in local_obj_cmdsets if cset): cset.duplicates = cset.old_duplicates + # important - this syncs the CmdSetHandler's .current field with the + # true current cmdset! + if cmdset: + caller.cmdset.current = cmdset + returnValue(cmdset) except ErrorReported: raise diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index f296982905..4437ac7a6c 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -106,9 +106,9 @@ class CmdSet(object, metaclass=_CmdSetMeta): commands preference. duplicates - determines what happens when two sets of equal - priority merge. Default has the first of them in the + priority merge (only). Defaults to None and has the first of them in the merger (i.e. A above) automatically taking - precedence. But if allow_duplicates is true, the + precedence. But if `duplicates` is true, the result will be a merger with more than one of each name match. This will usually lead to the account receiving a multiple-match error higher up the road, @@ -119,6 +119,16 @@ class CmdSet(object, metaclass=_CmdSetMeta): select which ball to kick ... Allowing duplicates only makes sense for Union and Intersect, the setting is ignored for the other mergetypes. + Note that the `duplicates` flag is *not* propagated in + a cmdset merger. So `A + B = C` will result in + a cmdset with duplicate commands, but C.duplicates will + be `None`. For duplication to apply to a whole cmdset + stack merge, _all_ cmdsets in the stack must have + `.duplicates=True` set. + Finally, if a final cmdset has `.duplicates=None` (the normal + unless created alone with another value), the cmdhandler + will assume True for object-based cmdsets and False for + all other. This is usually the most intuitive outcome. key_mergetype (dict) - allows the cmdset to define a unique mergetype for particular cmdsets. Format is @@ -144,14 +154,27 @@ class CmdSet(object, metaclass=_CmdSetMeta): mergetype = "Union" priority = 0 - # These flags, if set to None, will allow "pass-through" of lower-prio settings - # of True/False. If set to True/False, will override lower-prio settings. + # These flags, if set to None should be interpreted as 'I don't care' and, + # will allow "pass-through" even of lower-prio cmdsets' explicitly True/False + # options. If this is set to True/False however, priority matters. no_exits = None no_objs = None no_channels = None - # same as above, but if left at None in the final merged set, the - # cmdhandler will auto-assume True for Objects and stay False for all - # other entities. + # The .duplicates setting does not propagate and since duplicates can only happen + # on same-prio cmdsets, there is no concept of passthrough on `None`. + # The merger of two cmdsets always return in a cmdset with `duplicates=None` + # (even if the result may have duplicated commands). + # If a final cmdset has `duplicates=None` (normal, unless the cmdset is + # created on its own with the flag set), the cmdhandler will auto-assume it to be + # True for Object-based cmdsets and stay None/False for all other entities. + # + # Example: + # A and C has .duplicates=True, B has .duplicates=None (or False) + # B + A = BA, where BA will have duplicate cmds, but BA.duplicates = None + # BA + C = BAC, where BAC will have more duplication, but BAC.duplicates = None + # + # Basically, for the `.duplicate` setting to survive throughout a + # merge-stack, every cmdset in the stack must have `duplicates` set explicitly. duplicates = None permanent = False @@ -334,7 +357,15 @@ class CmdSet(object, metaclass=_CmdSetMeta): commands (str): Representation of commands in Cmdset. """ - return ", ".join([str(cmd) for cmd in sorted(self.commands, key=lambda o: o.key)]) + perm = "perm" if self.permanent else "non-perm" + options = ", ".join([ + "{}:{}".format(opt, "T" if getattr(self, opt) else "F") + for opt in ("no_exits", "no_objs", "no_channels", "duplicates") + if getattr(self, opt) is not None + ]) + options = (", " + options) if options else "" + return f"<CmdSet {self.key}, {self.mergetype}, {perm}, prio {self.priority}{options}>: " + ", ".join( + [str(cmd) for cmd in sorted(self.commands, key=lambda o: o.key)]) def __iter__(self): """ @@ -401,12 +432,15 @@ class CmdSet(object, metaclass=_CmdSetMeta): # pass through options whenever they are set, unless the merging or higher-prio # set changes the setting (i.e. has a non-None value). We don't pass through - # the duplicates setting; that is per-merge + # the duplicates setting; that is per-merge; the resulting .duplicates value + # is always None (so merging cmdsets must all have explicit values if wanting + # to cause duplicates). cmdset_c.no_channels = ( self.no_channels if cmdset_a.no_channels is None else cmdset_a.no_channels ) cmdset_c.no_exits = self.no_exits if cmdset_a.no_exits is None else cmdset_a.no_exits cmdset_c.no_objs = self.no_objs if cmdset_a.no_objs is None else cmdset_a.no_objs + cmdset_c.duplicates = None else: # B higher priority than A @@ -428,12 +462,15 @@ class CmdSet(object, metaclass=_CmdSetMeta): # pass through options whenever they are set, unless the higher-prio # set changes the setting (i.e. has a non-None value). We don't pass through - # the duplicates setting; that is per-merge + # the duplicates setting; that is per-merge; the resulting .duplicates value# + # is always None (so merging cmdsets must all have explicit values if wanting + # to cause duplicates). cmdset_c.no_channels = ( cmdset_a.no_channels if self.no_channels is None else self.no_channels ) cmdset_c.no_exits = cmdset_a.no_exits if self.no_exits is None else self.no_exits cmdset_c.no_objs = cmdset_a.no_objs if self.no_objs is None else self.no_objs + cmdset_c.duplicates = None # we store actual_mergetype since key_mergetypes # might be different from the main mergetype. diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index b2eb95c886..51853e8256 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -293,7 +293,10 @@ class CmdSetHandler(object): # the id of the "merged" current cmdset for easy access. self.key = None - # this holds the "merged" current command set + # this holds the "merged" current command set. Note that while the .update + # method updates this field in order to have it synced when operating on + # cmdsets in-code, when the game runs, this field is kept up-to-date by + # the cmdsethandler's get_and_merge_cmdsets! self.current = None # this holds a history of CommandSets self.cmdset_stack = [_EmptyCmdSet(cmdsetobj=self.obj)] @@ -311,27 +314,13 @@ class CmdSetHandler(object): Display current commands """ - string = "" + strings = ["<CmdSetHandler> stack:"] mergelist = [] if len(self.cmdset_stack) > 1: # We have more than one cmdset in stack; list them all for snum, cmdset in enumerate(self.cmdset_stack): - mergetype = self.mergetype_stack[snum] - permstring = "non-perm" - if cmdset.permanent: - permstring = "perm" - if mergetype != cmdset.mergetype: - mergetype = "%s^" % (mergetype) - string += "\n %i: <%s (%s, prio %i, %s)>: %s" % ( - snum, - cmdset.key, - mergetype, - cmdset.priority, - permstring, - cmdset, - ) - mergelist.append(str(snum)) - string += "\n" + mergelist.append(str(snum + 1)) + strings.append(f" {snum + 1}: {cmdset}") # Display the currently active cmdset, limited by self.obj's permissions mergetype = self.mergetype_stack[-1] @@ -339,27 +328,15 @@ class CmdSetHandler(object): merged_on = self.cmdset_stack[-2].key mergetype = _("custom {mergetype} on cmdset '{cmdset}'") mergetype = mergetype.format(mergetype=mergetype, cmdset=merged_on) + if mergelist: - tmpstring = _(" <Merged {mergelist} {mergetype}, prio {prio}>: {current}") - string += tmpstring.format( - mergelist="+".join(mergelist), - mergetype=mergetype, - prio=self.current.priority, - current=self.current, - ) + # current is a result of mergers + mergelist="+".join(mergelist) + strings.append(f" <Merged {mergelist}>: {self.current}") else: - permstring = "non-perm" - if self.current.permanent: - permstring = "perm" - tmpstring = _(" <{key} ({mergetype}, prio {prio}, {permstring})>:\n {keylist}") - string += tmpstring.format( - key=self.current.key, - mergetype=mergetype, - prio=self.current.priority, - permstring=permstring, - keylist=", ".join(cmd.key for cmd in sorted(self.current, key=lambda o: o.key)), - ) - return string.strip() + # current is a single cmdset + strings.append(" " + str(self.current)) + return "\n".join(strings).rstrip() def _import_cmdset(self, cmdset_path, emit_to_obj=None): """ @@ -381,12 +358,22 @@ class CmdSetHandler(object): def update(self, init_mode=False): """ Re-adds all sets in the handler to have an updated current - set. Args: init_mode (bool, optional): Used automatically right after this handler was created; it imports all permanent cmdsets from the database. + + Notes: + This method is necessary in order to always have a `.current` + cmdset when working with the cmdsethandler in code. But the + CmdSetHandler doesn't (cannot) consider external cmdsets and game + state. This means that the .current calculated from this method + will likely not match the true current cmdset as determined at + run-time by `cmdhandler.get_and_merge_cmdsets()`. So in a running + game the responsibility of keeping `.current` upt-to-date belongs + to the central `cmdhandler.get_and_merge_cmdsets()`! + """ if init_mode: # reimport all permanent cmdsets diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index e79b0d3cce..babf583391 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2429,13 +2429,13 @@ class CmdExamine(ObjManipCommand): ) return output - def format_output(self, obj, avail_cmdset): + def format_output(self, obj, current_cmdset): """ Helper function that creates a nice report about an object. Args: obj (any): Object to analyze. - avail_cmdset (CmdSet): Current cmdset for object. + current_cmdset (CmdSet): Current cmdset for object. Returns: str: The formatted string. @@ -2513,15 +2513,36 @@ class CmdExamine(ObjManipCommand): # cmdsets if not (len(obj.cmdset.all()) == 1 and obj.cmdset.current.key == "_EMPTY_CMDSET"): # all() returns a 'stack', so make a copy to sort. + + def _format_options(cmdset): + """helper for cmdset-option display""" + def _truefalse(string, value): + if value is None: + return "" + if value: + return f"{string}: T" + return f"{string}: F" + options = ", ".join( + _truefalse(opt, getattr(cmdset, opt)) + for opt in ("no_exits", "no_objs", "no_channels", "duplicates") + if getattr(cmdset, opt) is not None + ) + options = ", " + options if options else "" + return options + + # cmdset stored on us stored_cmdsets = sorted(obj.cmdset.all(), key=lambda x: x.priority, reverse=True) - output["Stored Cmdset(s)"] = "\n " + "\n ".join( - f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype}, prio {cmdset.priority})" - for cmdset in stored_cmdsets - if cmdset.key != "_EMPTY_CMDSET" - ) + stored = [] + for cmdset in stored_cmdsets: + if cmdset.key == "_EMPTY_CMDSET": + continue + options = _format_options(cmdset) + stored.append( + f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype}, prio {cmdset.priority}{options})") + output["Stored Cmdset(s)"] = "\n " + "\n ".join(stored) # this gets all components of the currently merged set - all_cmdsets = [(cmdset.key, cmdset) for cmdset in avail_cmdset.merged_from] + all_cmdsets = [(cmdset.key, cmdset) for cmdset in current_cmdset.merged_from] # we always at least try to add account- and session sets since these are ignored # if we merge on the object level. if hasattr(obj, "account") and obj.account: @@ -2551,15 +2572,24 @@ class CmdExamine(ObjManipCommand): pass all_cmdsets = [cmdset for cmdset in dict(all_cmdsets).values()] all_cmdsets.sort(key=lambda x: x.priority, reverse=True) - output["Merged Cmdset(s)"] = "\n " + "\n ".join( - f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype} prio {cmdset.priority})" - for cmdset in all_cmdsets - ) - # list the commands available to this object - avail_cmdset = sorted([cmd.key for cmd in avail_cmdset if cmd.access(obj, "cmd")]) - cmdsetstr = "\n" + utils.fill(", ".join(avail_cmdset), indent=2) + # the resulting merged cmdset + options = _format_options(current_cmdset) + merged = [ + f"<Current merged cmdset> ({current_cmdset.mergetype} prio {current_cmdset.priority}{options})"] + + # the merge stack + for cmdset in all_cmdsets: + options = _format_options(cmdset) + merged.append( + f"{cmdset.path} [{cmdset.key}] ({cmdset.mergetype} prio {cmdset.priority}{options})") + output["Merged Cmdset(s)"] = "\n " + "\n ".join(merged) + + # list the commands available to this object + current_commands = sorted([cmd.key for cmd in current_cmdset if cmd.access(obj, "cmd")]) + cmdsetstr = "\n" + utils.fill(", ".join(current_commands), indent=2) output[f"Commands available to {obj.key} (result of Merged CmdSets)"] = str(cmdsetstr) + # scripts if hasattr(obj, "scripts") and hasattr(obj.scripts, "all") and obj.scripts.all(): output["Scripts"] = "\n " + f"{obj.scripts}" diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8227de27bf..0ad9a932fc 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -971,7 +971,8 @@ class TestBuilding(CommandTest): self.call(building.CmdSetHome(), "Obj = Room2", "Home location of Obj was set to Room") def test_list_cmdsets(self): - self.call(building.CmdListCmdSets(), "", "<DefaultCharacter (Union, prio 0, perm)>:") + self.call(building.CmdListCmdSets(), "", + "<CmdSetHandler> stack:\n <CmdSet DefaultCharacter, Union, perm, prio 0>:") self.call(building.CmdListCmdSets(), "NotFound", "Could not find 'NotFound'") def test_typeclass(self): diff --git a/evennia/commands/tests.py b/evennia/commands/tests.py index a1ab9809f5..bb508a31c9 100644 --- a/evennia/commands/tests.py +++ b/evennia/commands/tests.py @@ -194,27 +194,71 @@ class TestCmdSetMergers(TestCase): self.assertEqual(len(cmdset_f.commands), 4) self.assertTrue(all(True for cmd in cmdset_f.commands if cmd.from_cmdset == "A")) - def test_option_transfer(self): - "Test transfer of cmdset options" + +class TestOptionTransferTrue(TestCase): + """ + Test cmdset-merge transfer of the cmdset-special options + (no_exits/channels/objs/duplicates etc) + + cmdset A has all True options + + """ + + def setUp(self): + super().setUp() + self.cmdset_a = _CmdSetA() + self.cmdset_b = _CmdSetB() + self.cmdset_c = _CmdSetC() + self.cmdset_d = _CmdSetD() + self.cmdset_a.priority = 0 + self.cmdset_b.priority = 0 + self.cmdset_c.priority = 0 + self.cmdset_d.priority = 0 + self.cmdset_a.no_exits = True + self.cmdset_a.no_objs = True + self.cmdset_a.no_channels = True + self.cmdset_a.duplicates = True + + def test_option_transfer__reverse_sameprio_passthrough(self): + """ + A has all True options, merges last (normal reverse merge), same prio. + The options should pass through to F since none of the other cmdsets + care to change the setting from their default None. + + Since A.duplicates = True, the final result is an union of duplicate + pairs (8 commands total). + + """ a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d - # the options should pass through since none of the other cmdsets care - # to change the setting from None. - a.no_exits = True - a.no_objs = True - a.no_channels = True - a.duplicates = True cmdset_f = d + c + b + a # reverse, same-prio self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertTrue(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 8) + + def test_option_transfer__forward_sameprio_passthrough(self): + """ + A has all True options, merges first (forward merge), same prio. This + should pass those options through since the other all have options set + to None. The exception is `duplicates` since that is determined by + the two last mergers in the chain both being True. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d cmdset_f = a + b + c + d # forward, same-prio self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertFalse(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_highprio_passthrough(self): + """ + A has all True options, merges last (normal reverse merge) with the + highest prio. This should also pass through. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d a.priority = 2 b.priority = 1 c.priority = 0 @@ -223,14 +267,35 @@ class TestCmdSetMergers(TestCase): self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertTrue(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_highprio_passthrough(self): + """ + A has all True options, merges first (forward merge). This is a bit + synthetic since it will never happen in practice, but logic should + still make it pass through. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 cmdset_f = a + b + c + d # forward, A top priority. This never happens in practice. self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertTrue(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_lowprio_passthrough(self): + """ + A has all True options, merges last (normal reverse merge) with the lowest + prio. This never happens (it would always merge first) but logic should hold + and pass through since the other cmdsets have None. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d a.priority = -1 b.priority = 0 c.priority = 1 @@ -239,32 +304,678 @@ class TestCmdSetMergers(TestCase): self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertFalse(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_passthrough(self): + """ + A has all True options, merges first (forward merge) with lowest prio. This + is the normal behavior for a low-prio cmdset. Passthrough should happen. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 cmdset_f = a + b + c + d # forward, A low prio self.assertTrue(cmdset_f.no_exits) self.assertTrue(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertFalse(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_highprio_block_passthrough(self): + """ + A has all True options, other cmdsets has False. A merges last with high + prio. A should retain its option values and override the others + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 c.no_exits = False b.no_objs = False d.duplicates = False # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, high prio + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_highprio_block_passthrough(self): + """ + A has all True options, other cmdsets has False. A merges last with high + prio. This situation should never happen, but logic should hold - the highest + prio's options should survive the merge process. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 + c.no_exits = False + b.no_channels = False + b.no_objs = False + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = a + b + c + d # forward, high prio, never happens + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_block(self): + """ + A has all True options, other cmdsets has False. A merges last with low + prio. This should result in its values being blocked and come out False. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = False + c.no_channels = False + b.no_objs = False + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = a + b + c + d # forward, A low prio + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_block_partial(self): + """ + A has all True options, other cmdsets has False excet C which has a None + for `no_channels`. A merges last with low + prio. This should result in its values being blocked and come out False + except for no_channels which passes through. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = False + c.no_channels = None # passthrough + b.no_objs = False + d.duplicates = False + # higher-prio sets will change the option up the chain cmdset_f = a + b + c + d # forward, A low prio self.assertFalse(cmdset_f.no_exits) self.assertFalse(cmdset_f.no_objs) self.assertTrue(cmdset_f.no_channels) - self.assertFalse(cmdset_f.duplicates) + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 4) - a.priority = 0 - b.priority = 0 + + def test_option_transfer__reverse_highprio_sameprio_order_last(self): + """ + A has all True options and highest prio, D has False and lowest prio, + others are passthrough. B has the same prio as A, with passthrough. + + Since A is merged last, this should give prio to A's options + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 2 c.priority = 0 - d.priority = 0 + d.priority = -1 + d.no_channels = False + d.no_exits = False + d.no_objs = None + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, A same prio, merged after b + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 8) + + def test_option_transfer__reverse_highprio_sameprio_order_first(self): + """ + A has all True options and highest prio, D has False and lowest prio, + others are passthrough. B has the same prio as A, with passthrough. + + While B, with None-values, is merged after A, A's options should have + replaced those of D at that point, and since B has passthrough the + final result should contain A's True options. + + Note that despite A having duplicates=True, there is no duplication in + the DB + A merger since they have different priorities. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 2 + c.priority = 0 + d.priority = -1 + d.no_channels = False + d.no_exits = False + d.no_objs = False + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = d + c + a + b # reverse, A same prio, merged before b + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_lowprio_block(self): + """ + A has all True options, other cmdsets has False. A merges last with low + prio. This usually doesn't happen- it should merge last. But logic should + hold and the low-prio cmdset's values should be blocked and come out False. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = False + d.no_channels = False + b.no_objs = False + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, A low prio, never happens + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + +class TestOptionTransferFalse(TestCase): + """ + Test cmdset-merge transfer of the cmdset-special options + (no_exits/channels/objs/duplicates etc) + + cmdset A has all False options + + """ + + def setUp(self): + super().setUp() + self.cmdset_a = _CmdSetA() + self.cmdset_b = _CmdSetB() + self.cmdset_c = _CmdSetC() + self.cmdset_d = _CmdSetD() + self.cmdset_a.priority = 0 + self.cmdset_b.priority = 0 + self.cmdset_c.priority = 0 + self.cmdset_d.priority = 0 + self.cmdset_a.no_exits = False + self.cmdset_a.no_objs = False + self.cmdset_a.no_channels = False + self.cmdset_a.duplicates = False + + def test_option_transfer__reverse_sameprio_passthrough(self): + """ + A has all False options, merges last (normal reverse merge), same prio. + The options should pass through to F since none of the other cmdsets + care to change the setting from their default None. + + Since A has duplicates=False, the result is a unique union of 4 cmds. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + cmdset_f = d + c + b + a # reverse, same-prio + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_sameprio_passthrough(self): + """ + A has all False options, merges first (forward merge), same prio. This + should pass those options through since the other all have options set + to None. The exception is `duplicates` since that is determined by + the two last mergers in the chain both being . + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + cmdset_f = a + b + c + d # forward, same-prio + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_highprio_passthrough(self): + """ + A has all False options, merges last (normal reverse merge) with the + highest prio. This should also pass through. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 + cmdset_f = d + c + b + a # reverse, A top priority + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_highprio_passthrough(self): + """ + A has all False options, merges first (forward merge). This is a bit + synthetic since it will never happen in practice, but logic should + still make it pass through. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 + cmdset_f = a + b + c + d # forward, A top priority. This never happens in practice. + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_lowprio_passthrough(self): + """ + A has all False options, merges last (normal reverse merge) with the lowest + prio. This never happens (it would always merge first) but logic should hold + and pass through since the other cmdsets have None. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + cmdset_f = d + c + b + a # reverse, A low prio. This never happens in practice. + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_passthrough(self): + """ + A has all False options, merges first (forward merge) with lowest prio. This + is the normal behavior for a low-prio cmdset. Passthrough should happen. + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + cmdset_f = a + b + c + d # forward, A low prio + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_highprio_block_passthrough(self): + """ + A has all False options, other cmdsets has True. A merges last with high + prio. A should retain its option values and override the others + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 + c.no_exits = True + b.no_objs = True + d.duplicates = True + # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, high prio + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_highprio_block_passthrough(self): + """ + A has all False options, other cmdsets has True. A merges last with high + prio. This situation should never happen, but logic should hold - the highest + prio's options should survive the merge process. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 1 + c.priority = 0 + d.priority = -1 + c.no_exits = True + b.no_channels = True + b.no_objs = True + d.duplicates = True + # higher-prio sets will change the option up the chain + cmdset_f = a + b + c + d # forward, high prio, never happens + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_block(self): + """ + A has all False options, other cmdsets has True. A merges last with low + prio. This should result in its values being blocked and come out False. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = True + c.no_channels = True + b.no_objs = True + d.duplicates = True + # higher-prio sets will change the option up the chain + cmdset_f = a + b + c + d # forward, A low prio + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__forward_lowprio_block_partial(self): + """ + A has all False options, other cmdsets has True excet C which has a None + for `no_channels`. A merges last with low + prio. This should result in its values being blocked and come out True + except for no_channels which passes through. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = True + c.no_channels = None # passthrough + b.no_objs = True + d.duplicates = True + # higher-prio sets will change the option up the chain + cmdset_f = a + b + c + d # forward, A low prio + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_sameprio_order_last(self): + """ + A has all False options and highest prio, D has True and lowest prio, + others are passthrough. B has the same prio as A, with passthrough. + + Since A is merged last, this should give prio to A's False options + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 2 + c.priority = 0 + d.priority = -1 + d.no_channels = True + d.no_exits = True + d.no_objs = True + d.duplicates = False + # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, A high prio, merged after b + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_sameprio_order_first(self): + """ + A has all False options and highest prio, D has True and lowest prio, + others are passthrough. B has the same prio as A, with passthrough. + + While B, with None-values, is merged after A, A's options should have + replaced those of D at that point, and since B has passthrough the + final result should contain A's False options. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 2 + c.priority = 0 + d.priority = -1 + d.no_channels = True + d.no_exits = True + d.no_objs = True + d.duplicates = False + + # higher-prio sets will change the option up the chain + cmdset_f = d + c + a + b # reverse, A high prio, merged before b + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + def test_option_transfer__reverse_lowprio_block(self): + """ + A has all False options, other cmdsets has True. A merges last with low + prio. This usually doesn't happen- it should merge last. But logic should + hold and the low-prio cmdset's values should be blocked and come out True. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = -1 + b.priority = 0 + c.priority = 1 + d.priority = 2 + c.no_exits = True + d.no_channels = True + b.no_objs = True + d.duplicates = True + # higher-prio sets will change the option up the chain + cmdset_f = d + c + b + a # reverse, A low prio, never happens + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + + +class TestDuplicateBehavior(TestCase): + """ + Test behavior of .duplicate option, which is a bit special in that it + doesn't propagate. + + `A.duplicates=True` for all tests. + + """ + + def setUp(self): + super().setUp() + self.cmdset_a = _CmdSetA() + self.cmdset_b = _CmdSetB() + self.cmdset_c = _CmdSetC() + self.cmdset_d = _CmdSetD() + self.cmdset_a.priority = 0 + self.cmdset_b.priority = 0 + self.cmdset_c.priority = 0 + self.cmdset_d.priority = 0 + self.cmdset_a.duplicates = True + + def test_reverse_sameprio_duplicate(self): + """ + Test of `duplicates` transfer which does not propagate. Only + A has duplicates=True. + + D + B = DB (no duplication, DB.duplication=None) + DB + C = DBC (no duplication, DBC.duplication=None) + DBC + A = final (duplication, final.duplication=None) + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + cmdset_f = d + b + c + a # two last mergers duplicates=True + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 8) + + def test_reverse_sameprio_duplicate(self): + """ + Test of `duplicates` transfer, which does not propagate. + C.duplication=True + + D + B = DB (no duplication, DB.duplication=None) + DB + C = DBC (duplication, DBC.duplication=None) + DBC + A = final (duplication, final.duplication=None) + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d c.duplicates = True cmdset_f = d + b + c + a # two last mergers duplicates=True + self.assertIsNone(cmdset_f.duplicates) self.assertEqual(len(cmdset_f.commands), 10) + def test_forward_sameprio_duplicate(self): + """ + Test of `duplicates` transfer which does not propagate. + C.duplication=True, merges later than A + + D + B = DB (no duplication, DB.duplication=None) + DB + A = DBA (duplication, DBA.duplication=None) + DBA + C = final (duplication, final.duplication=None) + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + c.duplicates = True + cmdset_f = d + b + a + c # two last mergers duplicates=True + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 10) + + def test_reverse_sameprio_duplicate_reverse(self): + """ + Test of `duplicates` transfer which does not propagate. + C.duplication=False (explicit), merges before A. This behavior is the + same as if C.duplication=None, since A merges later and takes + precedence. + + D + B = DB (no duplication, DB.duplication=None) + DB + C = DBC (no duplication, DBC.duplication=None) + DBC + A = final (duplication, final.duplication=None) + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + c.duplicates = False + cmdset_f = d + b + c + a # a merges last, takes precedence + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 8) + + def test_reverse_sameprio_duplicate_forward(self): + """ + Test of `duplicates` transfer which does not propagate. + C.duplication=False (explicit), merges after A. This just means + only A causes duplicates, earlier in the chain. + + D + B = DB (no duplication, DB.duplication=None) + DB + A = DBA (duplication, DBA.duplication=None) + DBA + C = final (no duplication, final.duplication=None) + + Note that DBA has 8 cmds due to A merging onto DB with duplication, + but since C merges onto this with no duplication, the union will hold + 6 commands, since C has two commands that replaces the 4 duplicates + with uniques copies from C. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + c.duplicates = False + cmdset_f = d + b + a + c # a merges before c + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 6) + + +class TestOptionTransferReplace(TestCase): + """ + Test option transfer through more complex merge types. + """ + def setUp(self): + super().setUp() + self.cmdset_a = _CmdSetA() + self.cmdset_b = _CmdSetB() + self.cmdset_c = _CmdSetC() + self.cmdset_d = _CmdSetD() + self.cmdset_a.priority = 0 + self.cmdset_b.priority = 0 + self.cmdset_c.priority = 0 + self.cmdset_d.priority = 0 + self.cmdset_a.no_exits = True + self.cmdset_a.no_objs = True + self.cmdset_a.no_channels = True + self.cmdset_a.duplicates = True + + def test_option_transfer__replace_reverse_highprio(self): + """ + A has all options True and highest priority. C has them False and is + Replace-type. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.priority = 2 + b.priority = 2 + c.priority = 0 + c.mergetype = "Replace" + c.no_channels = False + c.no_exits = False + c.no_objs = False + c.duplicates = False + d.priority = -1 + + cmdset_f = d + c + b + a # reverse, A high prio, C Replace + self.assertTrue(cmdset_f.no_exits) + self.assertTrue(cmdset_f.no_objs) + self.assertTrue(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 7) + + def test_option_transfer__replace_reverse_highprio_from_false(self): + """ + Inverse of previous test: A has all options False and highest priority. + C has them True and is Replace-type. + + """ + a, b, c, d = self.cmdset_a, self.cmdset_b, self.cmdset_c, self.cmdset_d + a.no_exits = False + a.no_objs = False + a.no_channels = False + a.duplicates = False + + a.priority = 2 + b.priority = 2 + c.priority = 0 + c.mergetype = "Replace" + c.no_channels = True + c.no_exits = True + c.no_objs = True + c.duplicates = True + d.priority = -1 + + cmdset_f = d + c + b + a # reverse, A high prio, C Replace + self.assertFalse(cmdset_f.no_exits) + self.assertFalse(cmdset_f.no_objs) + self.assertFalse(cmdset_f.no_channels) + self.assertIsNone(cmdset_f.duplicates) + self.assertEqual(len(cmdset_f.commands), 4) + # test cmdhandler functions diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/tutorialmenu.py index e40eb1a028..6d886d06e0 100644 --- a/evennia/contrib/tutorial_world/tutorialmenu.py +++ b/evennia/contrib/tutorial_world/tutorialmenu.py @@ -179,6 +179,7 @@ class DemoCommandSetRoom(CmdSet): key = "cmd_demo_cmdset_room" priority = 2 no_exits = False + no_objs = False def at_cmdset_creation(self): from evennia import default_cmds diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d525172b4f..6f9bf37020 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -57,7 +57,7 @@ both one or two arguments interchangeably. It also accepts nodes that takes **kwargs. The menu tree itself is available on the caller as -`caller.ndb._menutree`. This makes it a convenient place to store +`caller.ndb._evmenu`. This makes it a convenient place to store temporary state variables between nodes, since this NAttribute is deleted when the menu is exited. @@ -390,28 +390,28 @@ class CmdEvMenuNode(Command): caller = self.caller # we store Session on the menu since this can be hard to # get in multisession environemtns if caller is an Account. - menu = caller.ndb._menutree + menu = caller.ndb._evmenu if not menu: if _restore(caller): return orig_caller = caller caller = caller.account if hasattr(caller, "account") else None - menu = caller.ndb._menutree if caller else None + menu = caller.ndb._evmenu if caller else None if not menu: if caller and _restore(caller): return caller = self.session - menu = caller.ndb._menutree + menu = caller.ndb._evmenu if not menu: # can't restore from a session - err = "Menu object not found as %s.ndb._menutree!" % orig_caller + err = "Menu object not found as %s.ndb._evmenu!" % 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 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 + caller.ndb._evmenu._session = self.session # we have a menu, use it. menu.parse_input(self.raw_string) @@ -539,16 +539,16 @@ class EvMenu: `persistent` flag is deactivated. Kwargs: - any (any): All kwargs will become initialization variables on `caller.ndb._menutree`, + any (any): All kwargs will become initialization variables on `caller.ndb._evmenu`, to be available at run. Raises: EvMenuError: If the start/end node is not found in menu tree. Notes: - While running, the menu is stored on the caller as `caller.ndb._menutree`. Also + While running, the menu is stored on the caller as `caller.ndb._evmenu`. Also the current Session (from the Command, so this is still valid in multisession - environments) is available through `caller.ndb._menutree._session`. The `_menutree` + environments) is available through `caller.ndb._evmenu._session`. The `_evmenu` property is a good one for storing intermediary data on between nodes since it will be automatically deleted when the menu closes. @@ -621,15 +621,18 @@ class EvMenu: for key, val in kwargs.items(): setattr(self, key, val) - if self.caller.ndb._menutree: + if self.caller.ndb._evmenu: # an evmenu already exists - we try to close it cleanly. Note that this will # not fire the previous menu's end node. try: - self.caller.ndb._menutree.close_menu() + self.caller.ndb._evmenu.close_menu() except Exception: pass # store ourself on the object + self.caller.ndb._evmenu = self + + # DEPRECATED - for backwards-compatibility self.caller.ndb._menutree = self if persistent: @@ -649,7 +652,7 @@ class EvMenu: caller.attributes.add("_menutree_saved", (self.__class__, (menudata,), calldict)) caller.attributes.add("_menutree_saved_startnode", (startnode, startnode_input)) except Exception as err: - caller.msg(_ERROR_PERSISTENT_SAVING.format(error=err), session=self._session) + self.msg(_ERROR_PERSISTENT_SAVING.format(error=err)) logger.log_trace(_TRACE_PERSISTENT_SAVING) persistent = False @@ -761,7 +764,7 @@ class EvMenu: ret = callback(self.caller) except EvMenuError: errmsg = _ERR_GENERAL.format(nodename=callback) - self.caller.msg(errmsg, self._session) + self.msg(errmsg) logger.log_trace() raise @@ -786,7 +789,7 @@ class EvMenu: try: node = self._menutree[nodename] except KeyError: - self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename)) raise EvMenuError try: kwargs["_current_nodename"] = nodename @@ -796,11 +799,11 @@ class EvMenu: else: nodetext, options = ret, None except KeyError: - self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + self.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename)) logger.log_trace() raise EvMenuError except Exception: - self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) + self.msg(_ERR_GENERAL.format(nodename=nodename)) logger.log_trace() raise @@ -810,6 +813,23 @@ class EvMenu: return nodetext, options + def msg(self, txt): + """ + This is a central point for sending return texts to the caller. It + allows for a central point to add custom messaging when creating custom + EvMenu overrides. + + Args: + txt (str): The text to send. + + Notes: + By default this will send to the same session provided to EvMenu + (if `session` kwarg was provided to `EvMenu.__init__`). It will + also send it with a `type=menu` for the benefit of OOB/webclient. + + """ + self.caller.msg(text=(txt, {"type": "menu"}), session=self._session) + def run_exec(self, nodename, raw_string, **kwargs): """ NOTE: This is deprecated. Use `goto` directly instead. @@ -856,7 +876,7 @@ class EvMenu: 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) + self.msg("|r%s|n" % errmsg) logger.log_trace(errmsg) return @@ -1038,7 +1058,7 @@ class EvMenu: # avoid multiple calls from different sources self._quitting = True self.caller.cmdset.remove(EvMenuCmdSet) - del self.caller.ndb._menutree + del self.caller.ndb._evmenu if self._persistent: self.caller.attributes.remove("_menutree_saved") self.caller.attributes.remove("_menutree_saved_startnode") @@ -1102,7 +1122,7 @@ class EvMenu: ) + "\n |y... END MENU DEBUG|n" ) - self.caller.msg(debugtxt) + self.msg(debugtxt) def parse_input(self, raw_string): """ @@ -1137,17 +1157,17 @@ class EvMenu: 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) + self.msg(_HELP_NO_OPTION_MATCH) except EvMenuGotoAbortMessage as err: # custom interrupt from inside a goto callable - print the message and # stay on the current node. - self.caller.msg(str(err), session=self._session) + self.msg(str(err)) def display_nodetext(self): - self.caller.msg(self.nodetext, session=self._session) + self.msg(self.nodetext) def display_helptext(self): - self.caller.msg(self.helptext, session=self._session) + self.msg(self.helptext) # formatters - override in a child class @@ -1732,7 +1752,6 @@ def parse_menu_template(caller, menu_template, goto_callables=None): # if we have a pattern, build the arguments for _default later pattern = main_key[len(_OPTION_INPUT_MARKER):].strip() inputparsemap[pattern] = goto - print(f"main_key {main_key} {pattern} {goto}") else: # a regular goto string/callable target option = { From 88ae4041eba41136884643ce1c9fef56f37b91ed Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sat, 3 Oct 2020 23:51:54 +0200 Subject: [PATCH 20/34] Automatic cleanup of evmenu-template storage --- evennia/utils/evmenu.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6f9bf37020..84104569c7 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1064,6 +1064,8 @@ class EvMenu: self.caller.attributes.remove("_menutree_saved_startnode") if self.cmd_on_exit is not None: self.cmd_on_exit(self.caller, self) + # special for template-generated menues + del self.caller.db._evmenu_template_contents def print_debug_info(self, arg): """ @@ -1701,7 +1703,7 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): def _generated_node(caller, raw_string, **kwargs): - text, options = caller.db._generated_menu_contents[kwargs["_current_nodename"]] + text, options = caller.db._evmenu_template_contents[kwargs["_current_nodename"]] return text, options @@ -1803,7 +1805,7 @@ def parse_menu_template(caller, menu_template, goto_callables=None): options = _parse_options(nodename, optiontxt, goto_callables) content_map[nodename] = (text, options) nodetree[nodename] = _generated_node - caller.db._generated_menu_contents = content_map + caller.db._evmenu_template_contents = content_map return nodetree From b3e466dfa3173aa8082f07ed075b1ae51466a718 Mon Sep 17 00:00:00 2001 From: Kami Hines <aka.avarice@gmail.com> Date: Tue, 1 Sep 2020 18:11:27 -0700 Subject: [PATCH 21/34] Find user IP when webclient fallsback to AJAX --- evennia/server/portal/webclient_ajax.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/evennia/server/portal/webclient_ajax.py b/evennia/server/portal/webclient_ajax.py index 1447c60805..739580f994 100644 --- a/evennia/server/portal/webclient_ajax.py +++ b/evennia/server/portal/webclient_ajax.py @@ -182,6 +182,16 @@ class AjaxWebClient(resource.Resource): csessid = self.get_client_sessid(request) remote_addr = request.getClientIP() + + if remote_addr in settings.UPSTREAM_IPS and request.getHeader("x-forwarded-for"): + addresses = [x.strip() for x in request.getHeader("x-forwarded-for").split(",")] + addresses.reverse() + + for addr in addresses: + if addr not in settings.UPSTREAM_IPS: + remote_addr = addr + break + host_string = "%s (%s:%s)" % ( _SERVERNAME, request.getRequestHostname(), From ec1926d002b6130d8b01d5c005712cc80de9c479 Mon Sep 17 00:00:00 2001 From: Kami Hines <aka.avarice@gmail.com> Date: Sat, 3 Oct 2020 21:13:16 -0700 Subject: [PATCH 22/34] Fix login/out announcement bug based on server time settings --- evennia/accounts/accounts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index fa76774e8e..69e7b10d8c 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -1262,7 +1262,10 @@ class DefaultAccount(AccountDB, metaclass=TypeclassBase): ] except Exception: logger.log_trace() - now = timezone.localtime() + if settings.USE_TZ: + now = timezone.localtime() + else: + now = timezone.now() now = "%02i-%02i-%02i(%02i:%02i)" % (now.year, now.month, now.day, now.hour, now.minute) if _MUDINFO_CHANNEL: _MUDINFO_CHANNEL.tempmsg(f"[{_MUDINFO_CHANNEL.key}, {now}]: {message}") From cdd74e73b4402c25eba915cd03e60eb156090bb7 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 09:26:05 +0200 Subject: [PATCH 23/34] Better naming of tutorialmenu.py --- evennia/contrib/tutorial_world/{tutorialmenu.py => intro_menu.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename evennia/contrib/tutorial_world/{tutorialmenu.py => intro_menu.py} (100%) diff --git a/evennia/contrib/tutorial_world/tutorialmenu.py b/evennia/contrib/tutorial_world/intro_menu.py similarity index 100% rename from evennia/contrib/tutorial_world/tutorialmenu.py rename to evennia/contrib/tutorial_world/intro_menu.py From 8c443a9b07f5fbff8da544c9edfa634ad76358ca Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 13:44:12 +0200 Subject: [PATCH 24/34] Clean up 'page' listing output --- evennia/commands/default/comms.py | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index ed7ff7ba73..1c20f14bc4 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -808,17 +808,34 @@ class CmdPage(COMMAND_DEFAULT_CLASS): lastpages = pages[-number:] else: lastpages = pages - template = "|w%s|n |c%s|n to |c%s|n: %s" - lastpages = "\n ".join( - template - % ( - utils.datetime_format(page.date_created), - ",".join(obj.key for obj in page.senders), - "|n,|c ".join([obj.name for obj in page.receivers]), - page.message, + to_template = "|w{date}|n{sender}|n |cto {receiver}|n:> {message}" + from_template = "|w{date}|n{receiver}|n |gfrom {sender}|n:< {message}" + listing = [] + for page in lastpages: + receiver = "" + sender = "" + template = from_template + sending = False + if self.caller in page.senders: + template = to_template + sending = True + + if len(page.receivers) > 1 or sending: + receiver = "|n,|c ".join([obj.name for obj in page.receivers]) + + if len(page.senders) > 1 or not sending: + sender = "|n,|c".join(obj.key for obj in page.senders) + + listing.append( + template.format( + date=utils.datetime_format(page.date_created), + sender=sender, + receiver=receiver, + message=page.message, + ) + ) - for page in lastpages - ) + lastpages = "\n ".join(listing) if lastpages: string = "Your latest pages:\n %s" % lastpages From 32a13b63e7148c5c4d591f06295dadda8fee3f4e Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 13:44:46 +0200 Subject: [PATCH 25/34] More nodes to menu-tutorial contrib --- evennia/commands/default/comms.py | 43 ++- evennia/commands/default/general.py | 6 +- evennia/contrib/tutorial_world/intro_menu.py | 351 ++++++++++++++----- evennia/objects/objects.py | 6 +- evennia/prototypes/spawner.py | 11 +- evennia/utils/evmenu.py | 30 +- 6 files changed, 317 insertions(+), 130 deletions(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 1c20f14bc4..d984854389 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -808,27 +808,42 @@ class CmdPage(COMMAND_DEFAULT_CLASS): lastpages = pages[-number:] else: lastpages = pages - to_template = "|w{date}|n{sender}|n |cto {receiver}|n:> {message}" - from_template = "|w{date}|n{receiver}|n |gfrom {sender}|n:< {message}" + to_template = "|w{date}{clr} {sender}|nto{clr}{receiver}|n:> {message}" + from_template = "|w{date}{clr} {receiver}|nfrom{clr}{sender}|n:< {message}" listing = [] + prev_selfsend = False for page in lastpages: - receiver = "" - sender = "" - template = from_template - sending = False - if self.caller in page.senders: + multi_send = len(page.senders) > 1 + multi_recv = len(page.receivers) > 1 + sending = self.caller in page.senders + # self-messages all look like sends, so we assume they always + # come in close pairs and treat the second of the pair as the recv. + selfsend = sending and self.caller in page.receivers + if selfsend: + if prev_selfsend: + # this is actually a receive of a self-message + sending = False + prev_selfsend = False + else: + prev_selfsend = True + + clr = "|c" if sending else "|g" + + sender = f"|n,{clr}".join(obj.key for obj in page.senders) + receiver = f"|n,{clr}".join([obj.name for obj in page.receivers]) + if sending: template = to_template - sending = True - - if len(page.receivers) > 1 or sending: - receiver = "|n,|c ".join([obj.name for obj in page.receivers]) - - if len(page.senders) > 1 or not sending: - sender = "|n,|c".join(obj.key for obj in page.senders) + sender = f"{sender} " if multi_send else "" + receiver = f" {receiver}" if multi_recv else f" {receiver}" + else: + template = from_template + receiver = f"{receiver} " if multi_recv else "" + sender = f" {sender} " if multi_send else f" {sender}" listing.append( template.format( date=utils.datetime_format(page.date_created), + clr=clr, sender=sender, receiver=receiver, message=page.message, diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 12ec9acf62..6b739e022b 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -378,10 +378,12 @@ class CmdInventory(COMMAND_DEFAULT_CLASS): if not items: string = "You are not carrying anything." else: + from evennia.utils.ansi import raw as raw_ansi table = self.styled_table(border="header") for item in items: - table.add_row("|C%s|n" % item.name, item.db.desc or "") - string = "|wYou are carrying:\n%s" % table + table.add_row(f"|C{item.name}|n", + "{}|n".format(utils.crop(raw_ansi(item.db.desc), width=50) or "")) + string = f"|wYou are carrying:\n{table}" self.caller.msg(string) diff --git a/evennia/contrib/tutorial_world/intro_menu.py b/evennia/contrib/tutorial_world/intro_menu.py index 6d886d06e0..f35b49a52f 100644 --- a/evennia/contrib/tutorial_world/intro_menu.py +++ b/evennia/contrib/tutorial_world/intro_menu.py @@ -1,9 +1,14 @@ """ -Game tutor +Intro menu / game tutor Evennia contrib - Griatch 2020 -This contrib is a tutorial menu using the EvMenu menu-templating system. +This contrib is an intro-menu for general MUD and evennia usage using the +EvMenu menu-templating system. + +EvMenu templating is a way to create a menu using a string-format instead +of creating all nodes manually. Of course, for full functionality one must +still create the goto-callbacks. """ @@ -11,24 +16,92 @@ from evennia import create_object from evennia import CmdSet from evennia.utils.evmenu import parse_menu_template, EvMenu -# goto callables +# Goto callbacks and helper resources for the menu def do_nothing(caller, raw_string, **kwargs): + """ + Re-runs the current node + """ return None + def send_testing_tagged(caller, raw_string, **kwargs): - caller.msg(("This is a message tagged with 'testing' and " - "should appear in the pane you selected!\n " - f"You wrote: '{raw_string}'", {"type": "testing"})) - return None + """ + Test to send a message to a pane tagged with 'testing' in the webclient. -def send_string(caller, raw_string, **kwargs): - caller.msg(raw_string) + """ + caller.msg( + ( + "This is a message tagged with 'testing' and " + "should appear in the pane you selected!\n " + f"You wrote: '{raw_string}'", + {"type": "testing"}, + ) + ) return None -# resources for the look-demo +# Resources for the first help-command demo + + +class DemoCommandSetHelp(CmdSet): + """ + Demo the help command + """ + + key = "Help Demo Set" + priority = 2 + + def at_cmdset_creation(self): + from evennia import default_cmds + + self.add(default_cmds.CmdHelp()) + + +def goto_command_demo_help(caller, raw_string, **kwargs): + "Sets things up before going to the help-demo node" + _maintain_demo_room(caller, delete=True) + caller.cmdset.remove(DemoCommandSetRoom) + caller.cmdset.remove(DemoCommandSetComms) + caller.cmdset.add(DemoCommandSetHelp) # TODO - make persistent + return kwargs.get("gotonode") or "command_demo_help" + + +# Resources for the comms demo + + +class DemoCommandSetComms(CmdSet): + """ + Demo communications + """ + + key = "Color Demo Set" + priority = 2 + no_exits = True + no_objs = True + + def at_cmdset_creation(self): + from evennia import default_cmds + + self.add(default_cmds.CmdHelp()) + self.add(default_cmds.CmdSay()) + self.add(default_cmds.CmdPose()) + self.add(default_cmds.CmdPage()) + self.add(default_cmds.CmdColorTest()) + + +def goto_command_demo_comms(caller, raw_string, **kwargs): + """ + Setup and go to the color demo node. + """ + caller.cmdset.remove(DemoCommandSetHelp) + caller.cmdset.remove(DemoCommandSetRoom) + caller.cmdset.add(DemoCommandSetComms) + return kwargs.get("gotonode") or "comms_demo_start" + + +# Resources for the room demo _ROOM_DESC = """ This is a small and comfortable wood cabin. Bright sunlight is shining in @@ -111,6 +184,7 @@ This is a fist-sized stone covered in runes: """ + def _maintain_demo_room(caller, delete=False): """ Handle the creation/cleanup of demo assets. We store them @@ -135,98 +209,95 @@ def _maintain_demo_room(caller, delete=False): del caller.db.tutorial_world_demo_room_data elif not roomdata: # create and describe the cabin and box - room1 = create_object("evennia.objects.objects.DefaultRoom", - key="A small, cozy cabin") + room1 = create_object("evennia.objects.objects.DefaultRoom", key="A small, cozy cabin") room1.db.desc = _ROOM_DESC.strip() - box = create_object("evennia.objects.objects.DefaultObject", - key="small wooden box") + box = create_object( + "evennia.objects.objects.DefaultObject", key="small wooden box", location=room1 + ) box.db.desc = _BOX_DESC.strip() - box.location = room1 # create and describe the meadow and stone - room2 = create_object("evennia.objects.objects.DefaultRoom", - key="A lush summer meadow") + room2 = create_object("evennia.objects.objects.DefaultRoom", key="A lush summer meadow") room2.db.desc = _MEADOW_DESC.strip() - stone = create_object("evennia.objects.objects.DefaultObject", - key="carved stone") + stone = create_object( + "evennia.objects.objects.DefaultObject", key="carved stone", location=room2 + ) stone.db.desc = _STONE_DESC.strip() # make the linking exits - door_out = create_object("evennia.objects.objects.DefaultExit", - key="Door", - location=room1, - destination=room2) + door_out = create_object( + "evennia.objects.objects.DefaultExit", key="Door", location=room1, destination=room2 + ) door_out.db.desc = _DOOR_DESC_OUT.strip() - door_in = create_object("evennia.objects.objects.DefaultExit", - key="entrance to the cabin", - aliases=["door", "in"], - location=room2, - destination=room1) + door_in = create_object( + "evennia.objects.objects.DefaultExit", + key="entrance to the cabin", + aliases=["door", "in", "entrance"], + location=room2, + destination=room1, + ) door_in.db.desc = _DOOR_DESC_IN.strip() # store references for easy removal later - caller.db.tutorial_world_demo_room_data = (caller.location, - room1, box, - room2, stone, - door_out, door_in) + caller.db.tutorial_world_demo_room_data = ( + caller.location, + room1, + box, + room2, + stone, + door_out, + door_in, + ) # move caller into room caller.location = room1 + class DemoCommandSetRoom(CmdSet): """ - Demo the `look` command. + Demo some general in-game commands command. """ - key = "cmd_demo_cmdset_room" + + key = "Room Demo Set" priority = 2 no_exits = False no_objs = False def at_cmdset_creation(self): from evennia import default_cmds + self.add(default_cmds.CmdHelp()) self.add(default_cmds.CmdLook()) self.add(default_cmds.CmdGet()) self.add(default_cmds.CmdDrop()) + self.add(default_cmds.CmdInventory()) self.add(default_cmds.CmdExamine()) self.add(default_cmds.CmdPy()) + def goto_command_demo_room(caller, raw_string, **kwargs): - """Generate a little 2-room environment for testing out some commands.""" + """ + Setup and go to the demo-room node. Generates a little 2-room environment + for testing out some commands. + """ _maintain_demo_room(caller) - caller.cmdset.remove(DemoCommandSetRoom) + caller.cmdset.remove(DemoCommandSetHelp) + caller.cmdset.remove(DemoCommandSetComms) caller.cmdset.add(DemoCommandSetRoom) # TODO - make persistent return "command_demo_room" -# resources for the general command demo -class DemoCommandSetHelp(CmdSet): - """ - Demo other commands. - """ - key = "cmd_demo_cmdset_help" - priority = 2 +# register all callables that can be used in the menu template - def at_cmdset_creation(self): - from evennia import default_cmds - self.add(default_cmds.CmdHelp()) +GOTO_CALLABLES = { + "send_testing_tagged": send_testing_tagged, + "do_nothing": do_nothing, + "goto_command_demo_help": goto_command_demo_help, + "goto_command_demo_comms": goto_command_demo_comms, + "goto_command_demo_room": goto_command_demo_room, +} -def goto_command_demo_help(caller, raw_string, **kwargs): - _maintain_demo_room(caller, delete=True) - caller.cmdset.remove(DemoCommandSetRoom) - caller.cmdset.add(DemoCommandSetHelp) # TODO - make persistent - return "command_demo_help" - - -def command_passthrough(caller, raw_string, **kwargs): - cmd = kwargs.get("cmd") - on_success = kwargs.get('on_success') - if cmd: - caller.execute_cmd(cmd) - else: - caller.execute_cmd(raw_string) - return on_success - +# Main menu definition MENU_TEMPLATE = """ @@ -245,7 +316,10 @@ Write |wnext|n to continue or select a number to jump to that lesson. 1 (next);1;next;n: About Evennia -> about_evennia 2: What is a MUD/MU*? -> about_muds 3: Using the webclient -> using webclient - 4: Playing the game -> goto_command_demo_help() + 4: The help command -> goto_command_demo_help() + 5: Communicating with others -> goto_command_demo_help(gotonode='talk on channels') + 6: Using colors -> goto_command_demo_comms(gotonode='testing_colors') + 7: Moving and exploring -> goto_command_demo_room() # --------------------------------------------------------------------------------- @@ -271,7 +345,7 @@ to graphical MMORPG-style games like World of Warcraft. back;b: About Evennia -> about_evennia next;n: Using the webclient -> using webclient - back to top;t: start + back to start;start;t: start >: using webclient # --------------------------------------------------------------------------------- @@ -299,10 +373,10 @@ There is also some |wextra|n info to learn about customizing the webclient. ## OPTIONS - back: About MUDs -> about_muds + back;b: About MUDs -> about_muds extra: Customizing the webclient -> customizing the webclient - next: Playing the game -> goto_command_demo_help() - back to top: start + next;n: Playing the game -> goto_command_demo_help() + back to start;start: start >: goto_command_demo_help() # --------------------------------------------------------------------------------- @@ -334,7 +408,7 @@ to a web client pane with a specific tag that you set yourself. ## OPTIONS - back: using webclient + back;b: using webclient > test *: send tagged message to new pane -> send_testing_tagged() # --------------------------------------------------------------------------------- @@ -357,21 +431,21 @@ At the moment you only have |whelp|n and some |wChannel Names|n (the '<menu comm is just a placeholder to indicate you are using this menu). We'll add more commands as we get to them in this tutorial - but we'll only -cover a small handfull. Once you exit you'll find a lot more! Now let's try +cover a small handful. Once you exit you'll find a lot more! Now let's try those channels ... ## OPTIONS - back: Using the webclient -> using webclient - next: Channel commands -> talk on channels - back to top: start + back;b: Using the webclient -> using webclient + next;n: Talk on Channels -> talk on channels + back to start;start: start >: talk on channels # --------------------------------------------------------------------------------- ## NODE talk on channels -|wChannels|n are like in-game chatrooms. The |wChannel names|n help-category +|wChannels|n are like in-game chatrooms. The |wChannel Names|n help-category holds the names of the channels available to you right now. One such channel is |wpublic|n. Use |yhelp public|n to see how to use it. Try it: @@ -387,10 +461,103 @@ IRC support channel. ## OPTIONS - back: help on help -> goto_command_demo_help() - next: Moving and exploring -> goto_command_demo_room() - back to top: start - >: goto_command_demo_room() + back;b: Finding help -> goto_command_demo_help() + next;n: Talk to people in-game -> goto_command_demo_comms() + back to start;start: start + +# --------------------------------------------------------------------------------- + +# we get here via goto_command_demo_comms() + +## NODE comms_demo_start + +You can also chat with people inside the game. If you try |yhelp|n now you'll +find you have a few more commands available for trying this out. + + |ysay Hello there!|n + |y"Hello there!|n + +|wsay|n is used to talk to people in the same location you are. Everyone in the +room will see what you have to say. A single quote |y"|n is a convenient shortcut. + + |ypose smiles|n + |y:smiles|n + +|wpose|n (or |wemote|n) describes what you do to those nearby. This is a very simple +command by default, but it can be extended to much more complex parsing in order to +include other people/objects in the emote, reference things by a short-description etc. + +## OPTIONS + + next;n: Paging people -> paging_people + back;b: Talk on Channels -> talk on channels + back to start;start: start + +# --------------------------------------------------------------------------------- + +## NODE paging_people + +Halfway between talking on a |wChannel|n and chatting in your current location +with |wsay|n and |wpose|n, you can also |wpage|n people. This is like a private +message only they can see. + + |ypage <name> = Hello there! + page <name1>, <name2> = Hello both of you!|n + +If you are alone on the server, put your own name as |w<name>|n to test it and +page yourself. Write just |ypage|n to see your latest pages. This will also show +you if anyone paged you while you were offline. + +(By the way - do you think that the use of |y=|n above is strange? This is a +MUSH/MUX-style of syntax. If you don't like it, you can change it for your own +game by simply changing how the |wpose|n command parses its input.) + + +## OPTIONS + + next;n: Using colors -> testing_colors + back;b: Talk to people in-game -> comms_demo_start + back to start;start: start + +# --------------------------------------------------------------------------------- + +## NODE testing_colors + +You can add color in your text by the help of tags. However, remember that not +everyone will see your colors - it depends on their client (and some use +screenreaders). Using color can also make text harder to read. So use it +sparingly. + +To start coloring something |rred|n, add a ||r (red) marker and then +end with ||n (to go back to neutral/no-color): + + |ysay This is a ||rred||n text! + say This is a ||Rdark red||n text!|n + +You can also change the background: + + |ysay This is a ||[x||bblue text on a light-grey background!|n + +There are 16 base colors and as many background colors (called ANSI colors). Some +clients also supports so-called Xterm256 which gives a total of 256 colors. These are +given as |w||rgb|n, where r, g, b are the components of red, green and blue from 0-5: + + |ysay This is ||050solid green!|n + |ysay This is ||520an orange color!|n + |ysay This is ||[005||555white on bright blue background!|n + +If you don't see the expected colors from the above examples, it's because your +client does not support it - try out the Evennia webclient instead. To see all +color codes printed, try + + |ycolor ansi + |ycolor xterm + +## OPTIONS + + next;n: Moving and Exploring -> goto_command_demo_room() + back;b: Paging people -> paging_people + back to start;start: start # --------------------------------------------------------------------------------- @@ -398,42 +565,36 @@ IRC support channel. ## NODE command_demo_room -Another important command is '|ylook|n'. It's also abbreviated '|yl|n' since -it's used so much. Looking displays/redisplays your current location. So far in -this tutorial, using 'look' would just redisplay the menu. +For exploring the game, a very important command is '|ylook|n'. It's also +abbreviated '|yl|n' since it's used so much. Looking displays/redisplays your +current location. You can also use it to look closer at items in the world. So +far in this tutorial, using 'look' would just redisplay the menu. Try |ylook|n now. You have been quietly transported to a sunny cabin to look -around in. Explore a little. Use |ynext|n when you are done. +around in. Explore a little and use |ynext|n when you are done. ## OPTIONS - back: Channel commands -> talk on channels - next: end - back to top: start - + back;b: Channel commands -> talk on channels + next;n: end + back to start;start: start # --------------------------------------------------------------------------------- ## NODE end -Thankyou for going through the tutorial! +Thank you for going through the tutorial! """ -GOTO_CALLABLES = { - "command_passthrough": command_passthrough, - "send_testing_tagged": send_testing_tagged, - "do_nothing": do_nothing, - "send_string": send_string, - "goto_command_demo_help": goto_command_demo_help, - "goto_command_demo_room": goto_command_demo_room, -} - class TutorialEvMenu(EvMenu): def close_menu(self): """Custom cleanup actions when closing menu""" + self.caller.cmdset.remove(DemoCommandSetHelp) + self.caller.cmdset.remove(DemoCommandSetRoom) + self.caller.cmdset.remove(DemoCommandSetComms) _maintain_demo_room(self.caller, delete=True) super().close_menu() diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 862f7cd832..a5935a3497 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1940,12 +1940,12 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): # whisper mode msg_type = "whisper" msg_self = ( - '{self} whisper to {all_receivers}, "{speech}"' if msg_self is True else msg_self + '{self} whisper to {all_receivers}, "|n{speech}|n"' if msg_self is True else msg_self ) - msg_receivers = msg_receivers or '{object} whispers: "{speech}"' + msg_receivers = msg_receivers or '{object} whispers: "|n{speech}|n"' msg_location = None else: - msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self + msg_self = '{self} say, "|n{speech}|n"' if msg_self is True else msg_self msg_location = msg_location or '{object} says, "{speech}"' msg_receivers = msg_receivers or message diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index ab401af5bb..0329447706 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -9,7 +9,7 @@ There main function is `spawn(*prototype)`, where the `prototype` is a dictionary like this: ```python -from evennia.prototypes import prototypes +from evennia.prototypes import prototypes, spawner prot = { "prototype_key": "goblin", @@ -22,7 +22,10 @@ prot = { "tags": ["mob", "evil", ('greenskin','mob')] "attrs": [("weapon", "sword")] } +# spawn something with the prototype +goblin = spawner.spawn(prot) +# make this into a db-saved prototype (optional) prot = prototypes.create_prototype(prot) ``` @@ -82,13 +85,13 @@ import random { "prototype_key": "goblin_wizard", - "prototype_parent": GOBLIN, + "prototype_parent": "GOBLIN", "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] } GOBLIN_ARCHER = { - "prototype_parent": GOBLIN, + "prototype_parent": "GOBLIN", "key": "goblin archer", "attack_skill": (random, (5, 10))" "attacks": ["short bow"] @@ -104,7 +107,7 @@ ARCHWIZARD = { GOBLIN_ARCHWIZARD = { "key" : "goblin archwizard" - "prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD), + "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD"), } ``` diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 84104569c7..8c8a3befab 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1659,6 +1659,12 @@ def _process_callable(caller, goto, goto_callables, raw_string, for kwarg in gotokwargs.split(","): if kwarg and "=" in kwarg: key, value = [part.strip() for part in kwarg.split("=", 1)] + if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", + "evmenu_current_nodename", "evmenu_goto_callables"): + raise RuntimeError( + f"EvMenu template error: goto-callable '{goto}' uses a " + f"kwarg ({key}) that is reserved for the EvMenu templating " + "system. Rename the kwarg.") try: key = literal_eval(key) except ValueError: @@ -1675,17 +1681,17 @@ def _process_callable(caller, goto, goto_callables, raw_string, def _generated_goto_func(caller, raw_string, **kwargs): - goto = kwargs["goto"] - goto_callables = kwargs["goto_callables"] - current_nodename = kwargs["current_nodename"] + goto = kwargs["evmenu_goto"] + goto_callables = kwargs["evmenu_goto_callables"] + current_nodename = kwargs["evmenu_current_nodename"] return _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs) def _generated_input_goto_func(caller, raw_string, **kwargs): - gotomap = kwargs["gotomap"] - goto_callables = kwargs["goto_callables"] - current_nodename = kwargs["current_nodename"] + gotomap = kwargs["evmenu_gotomap"] + goto_callables = kwargs["evmenu_goto_callables"] + current_nodename = kwargs["evmenu_current_nodename"] # start with glob patterns for pattern, goto in gotomap.items(): @@ -1761,9 +1767,9 @@ def parse_menu_template(caller, menu_template, goto_callables=None): "goto": ( _generated_goto_func, { - "goto": goto, - "current_nodename": nodename, - "goto_callables": goto_callables, + "evmenu_goto": goto, + "evmenu_current_nodename": nodename, + "evmenu_goto_callables": goto_callables, }, ), } @@ -1779,9 +1785,9 @@ def parse_menu_template(caller, menu_template, goto_callables=None): "goto": ( _generated_input_goto_func, { - "gotomap": inputparsemap, - "current_nodename": nodename, - "goto_callables": goto_callables, + "evmenu_gotomap": inputparsemap, + "evmenu_current_nodename": nodename, + "evmenu_goto_callables": goto_callables, }, ), } From abeace4e9e7e107e5e32f7f3aadc48c5148bce2a Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 19:11:55 +0200 Subject: [PATCH 26/34] Complete tutorial-intro menu --- evennia/contrib/tutorial_world/intro_menu.py | 257 +++++++++++++++---- evennia/utils/evmenu.py | 37 ++- 2 files changed, 239 insertions(+), 55 deletions(-) diff --git a/evennia/contrib/tutorial_world/intro_menu.py b/evennia/contrib/tutorial_world/intro_menu.py index f35b49a52f..237587bd49 100644 --- a/evennia/contrib/tutorial_world/intro_menu.py +++ b/evennia/contrib/tutorial_world/intro_menu.py @@ -107,17 +107,18 @@ _ROOM_DESC = """ This is a small and comfortable wood cabin. Bright sunlight is shining in through the windows. -Use |ylook box|n or |yl box|n to examine the box in this room. +Use |ylook sign|n or |yl sign|n to examine the wooden sign nailed to the wall. + """ -_BOX_DESC = """ -The box is made of wood. On it, letters are engraved, reading: +_SIGN_DESC = """ +The small sign reads: Good! Now try '|ylook small|n'. - You'll get an error! There are two things that 'small' could refer to here - - the 'small box' or the 'small, cozy cabin' itself. You will get a list of the - possibilities. + ... You'll get a multi-match error! There are two things that 'small' could + refer to here - the 'small wooden sign' or the 'small, cozy cabin' itself. You will + get a list of the possibilities. You could either tell Evennia which one you wanted by picking a unique part of their name (like '|ylook cozy|n') or use the number in the list to pick @@ -133,7 +134,7 @@ The box is made of wood. On it, letters are engraved, reading: it's because you are playing with Builder-privileges or higher. Regular players will not see the numbers. - Next look at the |wdoor|n. + Next try |ylook door|n. """ @@ -162,7 +163,7 @@ _MEADOW_DESC = """ This is a lush meadow, just outside a cozy cabin. It's surrounded by trees and sunlight filters down from a clear blue sky. -There is a |wstone|n here. Try looking at it. +There is a |wstone|n here. Try looking at it! """ @@ -198,9 +199,9 @@ def _maintain_demo_room(caller, delete=False): # we delete directly for simplicity. We need to delete # in specific order to avoid deleting rooms moves # its contents to their default home-location - prev_loc, room1, box, room2, stone, door_out, door_in = roomdata + prev_loc, room1, sign, room2, stone, door_out, door_in = roomdata caller.location = prev_loc - box.delete() + sign.delete() stone.delete() door_out.delete() door_in.delete() @@ -210,15 +211,17 @@ def _maintain_demo_room(caller, delete=False): elif not roomdata: # create and describe the cabin and box room1 = create_object("evennia.objects.objects.DefaultRoom", key="A small, cozy cabin") - room1.db.desc = _ROOM_DESC.strip() - box = create_object( - "evennia.objects.objects.DefaultObject", key="small wooden box", location=room1 + room1.db.desc = _ROOM_DESC.lstrip() + sign = create_object( + "evennia.objects.objects.DefaultObject", key="small wooden sign", location=room1 ) - box.db.desc = _BOX_DESC.strip() + sign.db.desc = _SIGN_DESC.strip() + sign.locks.add("get:false()") + sign.db.get_err_msg = "The sign is nailed to the wall. It's not budging." # create and describe the meadow and stone room2 = create_object("evennia.objects.objects.DefaultRoom", key="A lush summer meadow") - room2.db.desc = _MEADOW_DESC.strip() + room2.db.desc = _MEADOW_DESC.lstrip() stone = create_object( "evennia.objects.objects.DefaultObject", key="carved stone", location=room2 ) @@ -226,7 +229,8 @@ def _maintain_demo_room(caller, delete=False): # make the linking exits door_out = create_object( - "evennia.objects.objects.DefaultExit", key="Door", location=room1, destination=room2 + "evennia.objects.objects.DefaultExit", key="Door", location=room1, + destination=room2, locks=["get:false()"], ) door_out.db.desc = _DOOR_DESC_OUT.strip() door_in = create_object( @@ -235,6 +239,7 @@ def _maintain_demo_room(caller, delete=False): aliases=["door", "in", "entrance"], location=room2, destination=room1, + locks=["get:false()"] ) door_in.db.desc = _DOOR_DESC_IN.strip() @@ -242,7 +247,7 @@ def _maintain_demo_room(caller, delete=False): caller.db.tutorial_world_demo_room_data = ( caller.location, room1, - box, + sign, room2, stone, door_out, @@ -303,62 +308,110 @@ MENU_TEMPLATE = """ ## NODE start -Welcome to the |cEvennia|n intro! From this menu you can learn some more about -the system and also the basics of how to play a text-based game. You can exit -this menu at any time by using "q" or "quit". +|g** Evennia introduction wizard **|n -For (a lot) more help, check out the documentation at http://www.evennia.com. +If you feel lost you can learn some of the basics of how to play a text-based +game here. You can also learn a little about the system and how to find more +help. You can exit this tutorial-wizard at any time by entering '|yq|n' or '|yquit|n'. -Write |wnext|n to continue or select a number to jump to that lesson. +Press |y<return>|n or write |ynext|n to step forward. Or select a number to jump to. ## OPTIONS - 1 (next);1;next;n: About Evennia -> about_evennia - 2: What is a MUD/MU*? -> about_muds + 1 (next);1;next;n: What is a MUD/MU*? -> about_muds + 2: About Evennia -> about_evennia 3: Using the webclient -> using webclient 4: The help command -> goto_command_demo_help() 5: Communicating with others -> goto_command_demo_help(gotonode='talk on channels') 6: Using colors -> goto_command_demo_comms(gotonode='testing_colors') 7: Moving and exploring -> goto_command_demo_room() - -# --------------------------------------------------------------------------------- - -## NODE about_evennia - -Evennia is a game engine for creating multiplayer online text-games. - -## OPTIONS - - back;b: Start -> start - next;n: About MUDs -> about_muds + 8: Conclusions & next steps-> conclusions >: about_muds # --------------------------------------------------------------------------------- ## NODE about_muds -The term MUD stands for Multi-user-Dungeon or -Dimension. These are the precursor -to graphical MMORPG-style games like World of Warcraft. +|g** About MUDs **|n +The term '|wMUD|n' stands for Multi-user-Dungeon or -Dimension. A MUD is +primarily played by inserting text |wcommands|n and getting text back. + +MUDS were the |wprecursors|n to graphical MMORPG-style games like World of +Warcraft. While not as mainstream as they once were, comparing a text-game to a +graphical game is like comparing a book to a movie - it's just a different +experience altogether. + +MUDs are |wdifferent|n from Interactive Fiction (IF) in that they are multiplayer +and usually has a consistent game world with many stories and protagonists +acting at the same time. + +Like there are many different styles of graphical MMOs, there are |wmany +variations|n of MUDs: They can be slow-paced or fast. They can cover fantasy, +sci-fi, horror or other genres. They can allow PvP or not and be casual or +hardcore, strategic, tactical, turn-based or play in real-time. + +Whereas 'MUD' is arguably the most well-known term, there are other terms +centered around particular game engines - such as MUSH, MOO, MUX, MUCK, LPMuds, +ROMs, Diku and others. Many people that played MUDs in the past used one of +these existing families of text game-servers, whether they knew it or not. + +|cEvennia|n is a newer text game engine designed to emulate almost any existing +gaming style you like and possibly any new ones you can come up with! ## OPTIONS - back;b: About Evennia -> about_evennia - next;n: Using the webclient -> using webclient + next;n: About Evennia -> about_evennia back to start;start;t: start + >: about_evennia + +# --------------------------------------------------------------------------------- + +## NODE about_evennia + +|g** About Evennia **|n + +|cEvennia|n is a Python game engine for creating multiplayer online text-games +(aka MUDs, MUSHes, MUX, MOOs...). It is open-source and |wfree to use|n, also for +commercial projects (BSD license). + +Out of the box, Evennia provides a |wfull, if empty game|n. Whereas you can play +via traditional telnet MUD-clients, the server runs your game's website and +offers a |wHTML5 webclient|n so that people can play your game in their browser +without downloading anything extra. + +Evennia deliberately |wdoes not|n hard-code any game-specific things like +combat-systems, races, skills, etc. They would not match what just you wanted +anyway! Whereas we do have optional contribs with many examples, most of our +users use them as inspiration to make their own thing. + +Evennia is developed entirely in |wPython|n, using modern developer practices. +The advantage of text is that even a solo developer or small team can +realistically make a competitive multiplayer game (as compared to a graphical +MMORPG which is one of the most expensive game types in existence to develop). +Many also use Evennia as a |wfun way to learn Python|n! + +## OPTIONS + + next;n: Using the webclient -> using webclient + back;b: About MUDs -> about_muds >: using webclient # --------------------------------------------------------------------------------- ## NODE using webclient -|rNote: This is only relevant if you use Evennia's HTML5 web client. If you use a +|g** Using the Webclient **|n + +|RNote: This is only relevant if you use Evennia's HTML5 web client. If you use a third-party (telnet) mud-client, you can skip this section.|n -Evennia's web client is (when you install the server locally) found by pointing -your web browser to +Evennia's web client is (for a local install) found by pointing your browser to + |yhttp://localhost:4001/webclient|n + For a live example, the public Evennia demo can be found at + |yhttps://demo.evennia.com/webclient|n The web client starts out having two panes - the input-pane for entering commands @@ -373,9 +426,9 @@ There is also some |wextra|n info to learn about customizing the webclient. ## OPTIONS - back;b: About MUDs -> about_muds extra: Customizing the webclient -> customizing the webclient next;n: Playing the game -> goto_command_demo_help() + back;b: About Evennia -> about_evennia back to start;start: start >: goto_command_demo_help() @@ -385,6 +438,8 @@ There is also some |wextra|n info to learn about customizing the webclient. ## NODE customizing the webclient +|g** Extra hints on customizing the Webclient **|n + |y1)|n The panes of the webclient can be resized and you can create additional panes. - Press the little plus (|w+|n) sign in the top left and a new tab will appear. @@ -410,6 +465,7 @@ to a web client pane with a specific tag that you set yourself. back;b: using webclient > test *: send tagged message to new pane -> send_testing_tagged() + >: using webclient # --------------------------------------------------------------------------------- @@ -417,7 +473,9 @@ to a web client pane with a specific tag that you set yourself. ## NODE command_demo_help -Evennia has about 90 default commands. They include useful administration/building +|g** Playing the game **|n + +Evennia has about |w90 default commands|n. They include useful administration/building commands and a few limited "in-game" commands to serve as examples. They are intended to be changed, extended and modified as you please. @@ -436,8 +494,8 @@ those channels ... ## OPTIONS - back;b: Using the webclient -> using webclient next;n: Talk on Channels -> talk on channels + back;b: Using the webclient -> using webclient back to start;start: start >: talk on channels @@ -445,6 +503,8 @@ those channels ... ## NODE talk on channels +|g** Talk on Channels **|n + |wChannels|n are like in-game chatrooms. The |wChannel Names|n help-category holds the names of the channels available to you right now. One such channel is |wpublic|n. Use |yhelp public|n to see how to use it. Try it: @@ -461,9 +521,10 @@ IRC support channel. ## OPTIONS - back;b: Finding help -> goto_command_demo_help() next;n: Talk to people in-game -> goto_command_demo_comms() + back;b: Finding help -> goto_command_demo_help() back to start;start: start + >: goto_command_demo_comms() # --------------------------------------------------------------------------------- @@ -471,14 +532,16 @@ IRC support channel. ## NODE comms_demo_start +|g** Talk to people in-game **|n + You can also chat with people inside the game. If you try |yhelp|n now you'll find you have a few more commands available for trying this out. |ysay Hello there!|n - |y"Hello there!|n + |y'Hello there!|n |wsay|n is used to talk to people in the same location you are. Everyone in the -room will see what you have to say. A single quote |y"|n is a convenient shortcut. +room will see what you have to say. A single quote |y'|n is a convenient shortcut. |ypose smiles|n |y:smiles|n @@ -492,11 +555,14 @@ include other people/objects in the emote, reference things by a short-descripti next;n: Paging people -> paging_people back;b: Talk on Channels -> talk on channels back to start;start: start + >: paging_people # --------------------------------------------------------------------------------- ## NODE paging_people +|g** Paging people **|n + Halfway between talking on a |wChannel|n and chatting in your current location with |wsay|n and |wpose|n, you can also |wpage|n people. This is like a private message only they can see. @@ -518,11 +584,14 @@ game by simply changing how the |wpose|n command parses its input.) next;n: Using colors -> testing_colors back;b: Talk to people in-game -> comms_demo_start back to start;start: start + >: testing_colors # --------------------------------------------------------------------------------- ## NODE testing_colors +|g** U|rs|yi|gn|wg |c|yc|wo|rl|bo|gr|cs |g**|n + You can add color in your text by the help of tags. However, remember that not everyone will see your colors - it depends on their client (and some use screenreaders). Using color can also make text harder to read. So use it @@ -558,6 +627,7 @@ color codes printed, try next;n: Moving and Exploring -> goto_command_demo_room() back;b: Paging people -> paging_people back to start;start: start + >: goto_command_demo_room() # --------------------------------------------------------------------------------- @@ -565,6 +635,8 @@ color codes printed, try ## NODE command_demo_room +|gMoving and Exploring|n + For exploring the game, a very important command is '|ylook|n'. It's also abbreviated '|yl|n' since it's used so much. Looking displays/redisplays your current location. You can also use it to look closer at items in the world. So @@ -575,16 +647,76 @@ around in. Explore a little and use |ynext|n when you are done. ## OPTIONS - back;b: Channel commands -> talk on channels - next;n: end + next;n: Conclusions -> conclusions + back;b: Channel commands -> testing_colors back to start;start: start + >: conclusions # --------------------------------------------------------------------------------- +## NODE conclusions + +|gConclusions|n + +That concludes this little quick-intro to using the base game commands of +Evennia. With this you should be able to continue exploring and also find help +if you get stuck! + +Write |ynext|n to end this wizard and continue to the tutorial-world quest! +If you want there is also some |wextra|n info for where to go beyond that. + +Good luck! + +## OPTIONS + + extra: Some more help on where to go next -> post scriptum + next;next;n: end + back;b: goto_command_demo_room() + back to start;start: start + >: end + +# --------------------------------------------------------------------------------- + +## NODE post scriptum + +|gWhere to next?|n + +After playing through the tutorial-world quest, if you aim to make a game with +Evennia you are wise to take a look at the |wEvennia documentation|n at + + |yhttps://github.com/evennia/evennia/wiki|n + +- You can start by trying to build some stuff by following the |wBuilder quick-start|n: + + |yhttps://github.com/evennia/evennia/wiki/Building-Quickstart|n + +- The tutorial-world may or may not be your cup of tea, but it does show off + several |wuseful tools|n of Evennia. You may want to check out how it works: + + |yhttps://github.com/evennia/evennia/wiki/Tutorial-World-Introduction|n + +- You can then continue looking through the |wTutorials|n and pick one that + fits your level of understanding. + + |yhttps://github.com/evennia/evennia/wiki/Tutorials|n + +- Make sure to |wjoin our forum|n and connect to our |wsupport chat|n! The + Evennia community is very active and friendly and no question is too simple. + You will often quickly get help. You can everything you need linked from + + |yhttp://www.evennia.com|n + +# --------------------------------------------------------------------------------- + +## OPTIONS + +back: conclusions +>: conclusions + + ## NODE end -Thank you for going through the tutorial! - +Thanks for trying out the tutorial! """ @@ -598,6 +730,27 @@ class TutorialEvMenu(EvMenu): _maintain_demo_room(self.caller, delete=True) super().close_menu() + def options_formatter(self, optionslist): + + navigation_keys = ("next", "back", "back to start") + + other = [] + navigation = [] + for key, desc in optionslist: + if key in navigation_keys: + desc = f" ({desc})" if desc else "" + navigation.append(f"|lc{key}|lt|w{key}|n|le{desc}") + else: + other.append((key, desc)) + navigation = ( + (" " + " |W|||n ".join(navigation) + " |W|||n " + "|wQ|Wuit|n") + if navigation + else "" + ) + other = super().options_formatter(other) + sep = "\n\n" if navigation and other else "" + + return f"{navigation}{sep}{other}" def testmenu(caller): menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 8c8a3befab..73feafee67 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1651,6 +1651,13 @@ _OPTION_COMMENT_START = "#" def _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs): + """ + Central helper for parsing a goto-callable (`funcname(**kwargs)`) out of + the right-hand-side of the template options and map this to an actual + callable registered with the template generator. This involves parsing the + func-name and running literal-eval on its kwargs. + + """ match = _RE_CALLABLE.match(goto) if match: gotofunc = match.group("funcname") @@ -1681,6 +1688,12 @@ def _process_callable(caller, goto, goto_callables, raw_string, def _generated_goto_func(caller, raw_string, **kwargs): + """ + This rerouter handles normal direct goto func call matches. + + key : ... -> goto_callable(**kwargs) + + """ goto = kwargs["evmenu_goto"] goto_callables = kwargs["evmenu_goto_callables"] current_nodename = kwargs["evmenu_current_nodename"] @@ -1689,9 +1702,18 @@ def _generated_goto_func(caller, raw_string, **kwargs): def _generated_input_goto_func(caller, raw_string, **kwargs): + """ + This goto-func acts as a rerouter for >-type line parsing (by acting as the + _default option). The patterns discovered in the menu maps to different + *actual* goto-funcs. We map to those here. + + >pattern: ... -> goto_callable + + """ gotomap = kwargs["evmenu_gotomap"] goto_callables = kwargs["evmenu_goto_callables"] current_nodename = kwargs["evmenu_current_nodename"] + raw_string = raw_string.strip("\n") # strip is necessary to catch empty return # start with glob patterns for pattern, goto in gotomap.items(): @@ -1704,18 +1726,23 @@ def _generated_input_goto_func(caller, raw_string, **kwargs): return _process_callable(caller, goto, goto_callables, raw_string, current_nodename, kwargs) # no match, show error - raise EvMenuGotoAbortMessage(_HELP_NO_OPTION_MATCH) def _generated_node(caller, raw_string, **kwargs): + """ + Every node in the templated menu will be this node, but with dynamically + changing text/options. It must be a global function like this because + otherwise we could not make the templated-menu persistent. + + """ text, options = caller.db._evmenu_template_contents[kwargs["_current_nodename"]] return text, options def parse_menu_template(caller, menu_template, goto_callables=None): """ - Parse menu-template string + Parse menu-template string. The main function of the EvMenu templating system. Args: caller (Object or Account): Entity using the menu. @@ -1724,6 +1751,9 @@ def parse_menu_template(caller, menu_template, goto_callables=None): on the form `callable(caller, raw_string, **kwargs)`. These are what is available to use in the `menu_template` string. + Returns: + dict: A `{"node": nodefunc}` menutree suitable to pass into EvMenu. + """ def _parse_options(nodename, optiontxt, goto_callables): @@ -1828,7 +1858,8 @@ def template2menu( ): """ Helper function to generate and start an EvMenu based on a menu template - string. + string. This will internall call `parse_menu_template` and run a default + EvMenu with its results. Args: caller (Object or Account): The entity using the menu. From a94e723d6b285aed1418055a1fdcc32cf0924c57 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 19:50:49 +0200 Subject: [PATCH 27/34] Add intro-menu to tutorial-world --- evennia/contrib/tutorial_world/build.ev | 25 +++++++------ evennia/contrib/tutorial_world/intro_menu.py | 39 ++++++++++++++------ evennia/contrib/tutorial_world/rooms.py | 27 +++++++++++++- evennia/server/initial_setup.py | 2 +- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/evennia/contrib/tutorial_world/build.ev b/evennia/contrib/tutorial_world/build.ev index 2ecd4fd121..a980b1fece 100644 --- a/evennia/contrib/tutorial_world/build.ev +++ b/evennia/contrib/tutorial_world/build.ev @@ -105,21 +105,24 @@ tutorial # @desc |gWelcome to the Evennia tutorial-world!|n + This small quest shows some examples of Evennia usage. - To get into the mood of this miniature quest, imagine you are an - adventurer out to find fame and fortune. You have heard rumours of an - old castle ruin by the coast. In its depth a warrior princess was - buried together with her powerful magical weapon - a valuable prize, - if it's true. Of course this is a chance to adventure that you - cannot turn down! + |gDo you want help with how to play? Write |yintro|g to get an introduction to + Evennia and the basics of playing!|n - You reach the coast in the midst of a raging thunderstorm. With wind - and rain screaming in your face you stand where the moor meet the sea - along a high, rocky coast ... + To get into the mood of this miniature quest, imagine you are an adventurer + out to find fame and fortune. You have heard rumours of an old castle ruin by + the coast. In its depth a warrior princess was buried together with her + powerful magical weapon - a valuable prize, if it's true. Of course this is a + chance to adventure that you cannot turn down! -Try 'tutorial' to get behind-the-scenes help anywhere, and 'give up' -if you want to abort. + You reach the coast in the midst of a raging thunderstorm. With wind and rain + screaming in your face you stand where the moor meet the sea along a high, + rocky coast ... + +Try '|yintro|n' for usage help. During the quest, write '|ytutorial|n' to get +behind-the-scenes help anywhere, and '|ygive up|n' to abandon the quest. |gwrite 'begin' to start your quest!|n diff --git a/evennia/contrib/tutorial_world/intro_menu.py b/evennia/contrib/tutorial_world/intro_menu.py index 237587bd49..4dcb68281d 100644 --- a/evennia/contrib/tutorial_world/intro_menu.py +++ b/evennia/contrib/tutorial_world/intro_menu.py @@ -229,8 +229,11 @@ def _maintain_demo_room(caller, delete=False): # make the linking exits door_out = create_object( - "evennia.objects.objects.DefaultExit", key="Door", location=room1, - destination=room2, locks=["get:false()"], + "evennia.objects.objects.DefaultExit", + key="Door", + location=room1, + destination=room2, + locks=["get:false()"], ) door_out.db.desc = _DOOR_DESC_OUT.strip() door_in = create_object( @@ -239,7 +242,7 @@ def _maintain_demo_room(caller, delete=False): aliases=["door", "in", "entrance"], location=room2, destination=room1, - locks=["get:false()"] + locks=["get:false()"], ) door_in.db.desc = _DOOR_DESC_IN.strip() @@ -574,10 +577,9 @@ If you are alone on the server, put your own name as |w<name>|n to test it and page yourself. Write just |ypage|n to see your latest pages. This will also show you if anyone paged you while you were offline. -(By the way - do you think that the use of |y=|n above is strange? This is a -MUSH/MUX-style of syntax. If you don't like it, you can change it for your own -game by simply changing how the |wpose|n command parses its input.) - +(By the way - depending on which games you are used to, you may think that the +use of |y=|n above is strange. This is a MUSH/MUX-style of syntax. For your own +game you can change the |wpose|n command to work however you prefer). ## OPTIONS @@ -721,7 +723,18 @@ Thanks for trying out the tutorial! """ +# ------------------------------------------------------------------------------------------- +# +# EvMenu implementation and access function +# +# ------------------------------------------------------------------------------------------- + + class TutorialEvMenu(EvMenu): + """ + Custom EvMenu for displaying the intro-menu + """ + def close_menu(self): """Custom cleanup actions when closing menu""" self.caller.cmdset.remove(DemoCommandSetHelp) @@ -743,16 +756,18 @@ class TutorialEvMenu(EvMenu): else: other.append((key, desc)) navigation = ( - (" " + " |W|||n ".join(navigation) + " |W|||n " + "|wQ|Wuit|n") - if navigation - else "" + (" " + " |W|||n ".join(navigation) + " |W|||n " + "|wQ|Wuit|n") if navigation else "" ) other = super().options_formatter(other) sep = "\n\n" if navigation and other else "" return f"{navigation}{sep}{other}" -def testmenu(caller): + +def init_menu(caller): + """ + Call to initialize the menu. + + """ menutree = parse_menu_template(caller, MENU_TEMPLATE, GOTO_CALLABLES) - # we'll use a custom EvMenu child later TutorialEvMenu(caller, menutree) diff --git a/evennia/contrib/tutorial_world/rooms.py b/evennia/contrib/tutorial_world/rooms.py index fa7840683b..16eae80a1d 100644 --- a/evennia/contrib/tutorial_world/rooms.py +++ b/evennia/contrib/tutorial_world/rooms.py @@ -385,6 +385,31 @@ SUPERUSER_WARNING = ( # # ------------------------------------------------------------- +class CmdEvenniaIntro(Command): + """ + Start the Evennia intro wizard. + + Usage: + intro + + """ + key = "intro" + + def func(self): + from .intro_menu import init_menu + # quell also superusers + if self.caller.account: + self.caller.account.execute_cmd("quell") + self.caller.msg("(Auto-quelling)") + init_menu(self.caller) + + +class CmdSetEvenniaIntro(CmdSet): + key = "Evennia Intro StartSet" + + def at_cmdset_creation(self): + self.add(CmdEvenniaIntro()) + class IntroRoom(TutorialRoom): """ @@ -404,6 +429,7 @@ class IntroRoom(TutorialRoom): "This assigns the health Attribute to " "the account." ) + self.cmdset.add(CmdSetEvenniaIntro, permanent=True) def at_object_receive(self, character, source_location): """ @@ -426,7 +452,6 @@ class IntroRoom(TutorialRoom): character.account.execute_cmd("quell") character.msg("(Auto-quelling while in tutorial-world)") - # ------------------------------------------------------------- # # Bridge - unique room diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index 67e5318973..8a6db3e2a3 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -29,7 +29,7 @@ LIMBO_DESC = _( """ Welcome to your new |wEvennia|n-based game! Visit http://www.evennia.com if you need help, want to contribute, report issues or just join the community. -As Account #1 you can create a demo/tutorial area with |w@batchcommand tutorial_world.build|n. +As Account #1 you can create a demo/tutorial area with '|wbatchcommand tutorial_world.build|n'. """ ) From 1746aaf06b36955acf144638f5669654e59fdd7b Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 21:26:07 +0200 Subject: [PATCH 28/34] Final unit tests for evmenu templating --- evennia/contrib/tutorial_world/intro_menu.py | 33 +++++++---- evennia/utils/evmenu.py | 61 ++++++++++++++------ evennia/utils/tests/test_evmenu.py | 13 +++++ 3 files changed, 77 insertions(+), 30 deletions(-) diff --git a/evennia/contrib/tutorial_world/intro_menu.py b/evennia/contrib/tutorial_world/intro_menu.py index 4dcb68281d..c36e2ce57e 100644 --- a/evennia/contrib/tutorial_world/intro_menu.py +++ b/evennia/contrib/tutorial_world/intro_menu.py @@ -290,10 +290,20 @@ def goto_command_demo_room(caller, raw_string, **kwargs): _maintain_demo_room(caller) caller.cmdset.remove(DemoCommandSetHelp) caller.cmdset.remove(DemoCommandSetComms) - caller.cmdset.add(DemoCommandSetRoom) # TODO - make persistent + caller.cmdset.add(DemoCommandSetRoom) return "command_demo_room" +def goto_cleanup_cmdsets(caller, raw_strings, **kwargs): + """ + Cleanup all cmdsets. + """ + caller.cmdset.remove(DemoCommandSetHelp) + caller.cmdset.remove(DemoCommandSetComms) + caller.cmdset.remove(DemoCommandSetRoom) + return kwargs.get("gotonode") + + # register all callables that can be used in the menu template GOTO_CALLABLES = { @@ -302,6 +312,7 @@ GOTO_CALLABLES = { "goto_command_demo_help": goto_command_demo_help, "goto_command_demo_comms": goto_command_demo_comms, "goto_command_demo_room": goto_command_demo_room, + "goto_cleanup_cmdsets": goto_cleanup_cmdsets, } @@ -365,7 +376,7 @@ gaming style you like and possibly any new ones you can come up with! ## OPTIONS next;n: About Evennia -> about_evennia - back to start;start;t: start + back to start;back;start;t: start >: about_evennia # --------------------------------------------------------------------------------- @@ -498,7 +509,7 @@ those channels ... ## OPTIONS next;n: Talk on Channels -> talk on channels - back;b: Using the webclient -> using webclient + back;b: Using the webclient -> goto_cleanup_cmdsets(gotonode='using webclient') back to start;start: start >: talk on channels @@ -556,7 +567,7 @@ include other people/objects in the emote, reference things by a short-descripti ## OPTIONS next;n: Paging people -> paging_people - back;b: Talk on Channels -> talk on channels + back;b: Talk on Channels -> goto_command_demo_help(gotonode='talk on channels') back to start;start: start >: paging_people @@ -627,7 +638,7 @@ color codes printed, try ## OPTIONS next;n: Moving and Exploring -> goto_command_demo_room() - back;b: Paging people -> paging_people + back;b: Paging people -> goto_command_demo_comms(gotonode='paging_people') back to start;start: start >: goto_command_demo_room() @@ -650,7 +661,7 @@ around in. Explore a little and use |ynext|n when you are done. ## OPTIONS next;n: Conclusions -> conclusions - back;b: Channel commands -> testing_colors + back;b: Channel commands -> goto_command_demo_comms(gotonode='testing_colors') back to start;start: start >: conclusions @@ -667,13 +678,11 @@ if you get stuck! Write |ynext|n to end this wizard and continue to the tutorial-world quest! If you want there is also some |wextra|n info for where to go beyond that. -Good luck! - ## OPTIONS - extra: Some more help on where to go next -> post scriptum - next;next;n: end - back;b: goto_command_demo_room() + extra: Where to go next -> post scriptum + next;next;n: End -> end + back;b: Moving and Exploring -> goto_command_demo_room() back to start;start: start >: end @@ -718,7 +727,7 @@ back: conclusions ## NODE end -Thanks for trying out the tutorial! +|gGood luck!|n """ diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 73feafee67..cbdce8241c 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1634,7 +1634,7 @@ _RE_NODE = re.compile(r"##\s*?NODE\s+?(?P<nodename>\S[\S\s]*?)$", re.I + re.M) _RE_OPTIONS_SEP = re.compile(r"##\s*?OPTIONS\s*?$", re.I + re.M) _RE_CALLABLE = re.compile(r"\S+?\(\)", re.I + re.M) _RE_CALLABLE = re.compile( - r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?=[\S\s]+?)\)|\(\))", re.I + re.M + r"(?P<funcname>\S+?)(?:\((?P<kwargs>[\S\s]+?)\)|\(\))", re.I + re.M ) _HELP_NO_OPTION_MATCH = _("Choose an option or try 'help'.") @@ -1664,23 +1664,23 @@ def _process_callable(caller, goto, goto_callables, raw_string, gotokwargs = match.group("kwargs") or "" if gotofunc in goto_callables: for kwarg in gotokwargs.split(","): - if kwarg and "=" in kwarg: - key, value = [part.strip() for part in kwarg.split("=", 1)] - if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", - "evmenu_current_nodename", "evmenu_goto_callables"): - raise RuntimeError( - f"EvMenu template error: goto-callable '{goto}' uses a " - f"kwarg ({key}) that is reserved for the EvMenu templating " - "system. Rename the kwarg.") - try: - key = literal_eval(key) - except ValueError: - pass - try: - value = literal_eval(value) - except ValueError: - pass - kwargs[key] = value + key, value = [part.strip() for part in kwarg.split("=", 1)] + if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", + "evmenu_current_nodename", "evmenu_goto_callables"): + raise RuntimeError( + f"EvMenu template error: goto-callable '{goto}' uses a " + f"kwarg ({kwarg}) that is reserved for the EvMenu templating " + "system. Rename the kwarg.") + try: + key = literal_eval(key) + except ValueError: + pass + try: + value = literal_eval(value) + except ValueError: + pass + kwargs[key] = value + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) if goto is None: return goto, {"generated_nodename": current_nodename} @@ -1755,6 +1755,23 @@ def parse_menu_template(caller, menu_template, goto_callables=None): dict: A `{"node": nodefunc}` menutree suitable to pass into EvMenu. """ + def _validate_kwarg(goto, kwarg): + """ + Validate goto-callable kwarg is on correct form. + """ + if not "=" in kwarg: + raise RuntimeError( + f"EvMenu template error: goto-callable '{goto}' has a " + f"non-kwarg argument ({kwarg}). All callables in the " + "template must have only keyword-arguments, or no " + "args at all.") + key, _ = [part.strip() for part in kwarg.split("=", 1)] + if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", + "evmenu_current_nodename", "evmenu_goto_callables"): + raise RuntimeError( + f"EvMenu template error: goto-callable '{goto}' uses a " + f"kwarg ({kwarg}) that is reserved for the EvMenu templating " + "system. Rename the kwarg.") def _parse_options(nodename, optiontxt, goto_callables): """ @@ -1779,6 +1796,14 @@ def parse_menu_template(caller, menu_template, goto_callables=None): if _OPTION_CALL_MARKER in goto: desc, goto = [part.strip() for part in goto.split(_OPTION_CALL_MARKER, 1)] + # validate callable + match = _RE_CALLABLE.match(goto) + if match: + kwargs = match.group("kwargs") + if kwargs: + for kwarg in kwargs.split(','): + _validate_kwarg(goto, kwarg) + # parse key [;aliases|pattern] key = [part.strip() for part in key.split(_OPTION_ALIAS_MARKER)] if not key: diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 718ccacbab..190afce564 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -327,3 +327,16 @@ class TestMenuTemplateParse(EvenniaTest): def test_template2menu(self): evmenu.template2menu(self.char1, self.menu_template, self.goto_callables) + + def test_parse_menu_fail(self): + template = """ + ## NODE + + Text + + ## OPTIONS + + next: callnode2(invalid) + """ + with self.assertRaises(RuntimeError): + evmenu.parse_menu_template(self.char1, template, self.goto_callables) From ef13565b764d3e3495d7e708f15a83f799251918 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Sun, 4 Oct 2020 21:27:42 +0000 Subject: [PATCH 29/34] Fix edge case in kwarg parsing for template --- evennia/utils/evmenu.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index cbdce8241c..ade6c93588 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1664,24 +1664,25 @@ def _process_callable(caller, goto, goto_callables, raw_string, gotokwargs = match.group("kwargs") or "" if gotofunc in goto_callables: for kwarg in gotokwargs.split(","): - key, value = [part.strip() for part in kwarg.split("=", 1)] - if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", - "evmenu_current_nodename", "evmenu_goto_callables"): - raise RuntimeError( - f"EvMenu template error: goto-callable '{goto}' uses a " - f"kwarg ({kwarg}) that is reserved for the EvMenu templating " - "system. Rename the kwarg.") - try: - key = literal_eval(key) - except ValueError: - pass - try: - value = literal_eval(value) - except ValueError: - pass - kwargs[key] = value + if kwarg and "=" in kwarg: + key, value = [part.strip() for part in kwarg.split("=", 1)] + if key in ("evmenu_goto", "evmenu_gotomap", "_current_nodename", + "evmenu_current_nodename", "evmenu_goto_callables"): + raise RuntimeError( + f"EvMenu template error: goto-callable '{goto}' uses a " + f"kwarg ({kwarg}) that is reserved for the EvMenu templating " + "system. Rename the kwarg.") + try: + key = literal_eval(key) + except ValueError: + pass + try: + value = literal_eval(value) + except ValueError: + pass + kwargs[key] = value - goto = goto_callables[gotofunc](caller, raw_string, **kwargs) + goto = goto_callables[gotofunc](caller, raw_string, **kwargs) if goto is None: return goto, {"generated_nodename": current_nodename} return goto, {"generated_nodename": goto} From 480ba6e20bf4fe2f639f3883936f2cb99c55e5e9 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Tue, 6 Oct 2020 09:41:45 +0200 Subject: [PATCH 30/34] Fix wrong display of websocket interface. Resolves #2214. --- evennia/server/portal/portal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index 4bdd8d41ad..6c47d6f82b 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -394,7 +394,7 @@ if WEBSERVER_ENABLED: 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 + w_ifacestr = "-%s" % w_interface port = WEBSOCKET_CLIENT_PORT class Websocket(WebSocketServerFactory): From 184b96697e237d543a4e5abca360bc3cf74d3d90 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Tue, 6 Oct 2020 14:26:06 +0200 Subject: [PATCH 31/34] Some more help when getting a tutorial weapon --- evennia/contrib/tutorial_world/objects.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index aa80bb6f3c..6fb8c7b7ee 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -834,6 +834,7 @@ class CmdAttack(Command): "stab", "slash", "chop", + "bash", "parry", "defend", ] @@ -876,7 +877,7 @@ class CmdAttack(Command): tstring = "%s stabs at you with %s. " % (self.caller.key, self.obj.key) ostring = "%s stabs at %s with %s. " % (self.caller.key, target.key, self.obj.key) self.caller.db.combat_parry_mode = False - elif cmdstring in ("slash", "chop"): + elif cmdstring in ("slash", "chop", "bash"): hit = float(self.obj.db.hit) # un modified due to slash damage = self.obj.db.damage # un modified due to slash string = "You slash with %s. " % self.obj.key @@ -1151,7 +1152,13 @@ class WeaponRack(TutorialObject): self.db.rack_id = "weaponrack_1" # these are prototype names from the prototype # dictionary above. - self.db.get_weapon_msg = "You find |c%s|n." + self.db.get_weapon_msg = """ + You find |c%s|n. While carrying this weapon, these actions are available: + |wstab/thrust/pierce <target>|n - poke at the enemy. More damage but harder to hit. + |wslash/chop/bash <target>|n - swipe at the enemy. Less damage but easier to hit. + |wdefend/parry|n - protect yourself and make yourself harder to hit. + """.strip() + self.db.no_more_weapons_msg = "you find nothing else of use." self.db.available_weapons = ["knife", "dagger", "sword", "club"] From 4a67d0482d4f89f3b5632f15846357907871f4b5 Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Tue, 6 Oct 2020 14:40:02 +0200 Subject: [PATCH 32/34] Correct indentation --- evennia/contrib/tutorial_world/objects.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 6fb8c7b7ee..8d08a945af 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -22,7 +22,7 @@ WeaponRack import random from evennia import DefaultObject, DefaultExit, Command, CmdSet -from evennia.utils import search, delay +from evennia.utils import search, delay, dedent from evennia.prototypes.spawner import spawn # ------------------------------------------------------------- @@ -1152,12 +1152,13 @@ class WeaponRack(TutorialObject): self.db.rack_id = "weaponrack_1" # these are prototype names from the prototype # dictionary above. - self.db.get_weapon_msg = """ - You find |c%s|n. While carrying this weapon, these actions are available: - |wstab/thrust/pierce <target>|n - poke at the enemy. More damage but harder to hit. - |wslash/chop/bash <target>|n - swipe at the enemy. Less damage but easier to hit. - |wdefend/parry|n - protect yourself and make yourself harder to hit. - """.strip() + self.db.get_weapon_msg = dedent( + """ + You find |c%s|n. While carrying this weapon, these actions are available: + |wstab/thrust/pierce <target>|n - poke at the enemy. More damage but harder to hit. + |wslash/chop/bash <target>|n - swipe at the enemy. Less damage but easier to hit. + |wdefend/parry|n - protect yourself and make yourself harder to hit.) + """).strip() self.db.no_more_weapons_msg = "you find nothing else of use." self.db.available_weapons = ["knife", "dagger", "sword", "club"] From 836649484f1a256cd43a0ee4188d52828daea42d Mon Sep 17 00:00:00 2001 From: Griatch <griatch@gmail.com> Date: Thu, 8 Oct 2020 19:19:12 +0200 Subject: [PATCH 33/34] Update changelog with merge of PR. Resolve #2200. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a691a5c64f..76986e1617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## Evennia 1.0 (2019-) (WIP) -- new `drop:holds()` lock default to limit dropping nonsensical things. Access check +- New `drop:holds()` lock default to limit dropping nonsensical things. Access check defaults to True for backwards-compatibility in 0.9, will be False in 1.0 +- Add `tags.has()` method for checking if an object has a tag or tags (PR by ChrisLR) ### Evennia 0.95 (master) - `is_typeclass(obj (Object), exact (bool))` now defaults to exact=False From 927130a98331eef516420b8fc42486a843420bde Mon Sep 17 00:00:00 2001 From: ChrisLR <arzhul@gmail.com> Date: Thu, 8 Oct 2020 13:59:25 -0400 Subject: [PATCH 34/34] Adds tests for Channels wholist property --- evennia/comms/tests.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py index b1ffe4beaf..5ff7377447 100644 --- a/evennia/comms/tests.py +++ b/evennia/comms/tests.py @@ -1,6 +1,6 @@ -from evennia.utils.test_resources import EvenniaTest from evennia import DefaultChannel from evennia.utils.create import create_message +from evennia.utils.test_resources import EvenniaTest class ObjectCreationTest(EvenniaTest): @@ -16,3 +16,34 @@ class ObjectCreationTest(EvenniaTest): msg = create_message("peewee herman", "heh-heh!", header="mail time!") self.assertTrue(msg) self.assertEqual(str(msg), "peewee herman->: heh-heh!") + + +class ChannelWholistTests(EvenniaTest): + def setUp(self): + super().setUp() + self.default_channel, _ = DefaultChannel.create("coffeetalk", description="A place to talk about coffee.") + self.default_channel.connect(self.obj1) + + def test_wholist_shows_subscribed_objects(self): + expected = "Obj" + result = self.default_channel.wholist + self.assertEqual(expected, result) + + def test_wholist_shows_none_when_empty(self): + # No one hates dogs + empty_channel, _ = DefaultChannel.create("doghaters", description="A place where dog haters unite.") + expected = "<None>" + result = empty_channel.wholist + self.assertEqual(expected, result) + + def test_wholist_does_not_show_muted_objects(self): + self.default_channel.mute(self.obj2) + expected = "Obj" + result = self.default_channel.wholist + self.assertEqual(expected, result) + + def test_wholist_shows_connected_object_as_bold(self): + self.default_channel.connect(self.char1) + expected = "Obj, |wChar|n" + result = self.default_channel.wholist + self.assertEqual(expected, result)